# Flyout

A non-modal panel anchored to a trigger on desktop that becomes a bottom drawer on mobile.

Flyout is an open-source pattern from suiss/ui for React. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/patterns/flyout

**React**

**Example**

```tsx
import {
  Flyout,
  FlyoutBody,
  FlyoutContent,
  FlyoutDescription,
  FlyoutHeader,
  FlyoutTitle,
  FlyoutTrigger,
} from "@/components/patterns/flyout"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export default function FlyoutDemo() {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <Flyout>
        <FlyoutTrigger render={<Button variant="outline" />}>
          Dimensions
        </FlyoutTrigger>
        <FlyoutContent>
          <FlyoutHeader>
            <FlyoutTitle>Dimensions</FlyoutTitle>
            <FlyoutDescription>
              Set the dimensions for the layer.
            </FlyoutDescription>
          </FlyoutHeader>
          <FlyoutBody className="grid gap-3">
            <div className="grid grid-cols-3 items-center gap-3">
              <Label htmlFor="flyout-width">Width</Label>
              <Input
                id="flyout-width"
                defaultValue="100%"
                className="col-span-2"
              />
            </div>
            <div className="grid grid-cols-3 items-center gap-3">
              <Label htmlFor="flyout-height">Height</Label>
              <Input
                id="flyout-height"
                defaultValue="25px"
                className="col-span-2"
              />
            </div>
          </FlyoutBody>
        </FlyoutContent>
      </Flyout>
    </div>
  )
}
```

`Flyout` shows supplementary content, such as a small form or a share link, next to the control that opened it. On desktop it renders a `Popover` positioned against the trigger. Below the `48rem` breakpoint it renders a bottom `Drawer` with a swipe handle, and each part switches to its drawer counterpart: `FlyoutHeader`, `FlyoutTitle` and `FlyoutDescription` map to the drawer's header, title and description, and `FlyoutBody` gains padding.

Use it for quick settings or filters that do not need a full modal.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/flyout
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add patterns flyout
```

### Manual

1. Install the dependencies and add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper):

```bash title="Terminal"
npm install @base-ui/react
```

2. Add the pieces it builds on: [Drawer](https://ui.suiss.app/docs/components/drawer), [Popover](https://ui.suiss.app/docs/components/popover), `use-mobile`.

3. Copy the source into `components/patterns/flyout.tsx`:

```tsx title="components/patterns/flyout.tsx"
"use client"

import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import * as React from "react"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"

type FlyoutContextProps = {
  isMobile: boolean
}

const FlyoutContext = React.createContext<FlyoutContextProps | null>(null)

function useFlyout() {
  const context = React.useContext(FlyoutContext)

  if (!context) {
    throw new Error("useFlyout must be used within a <Flyout />")
  }

  return context
}

function Flyout({
  open,
  defaultOpen,
  onOpenChange,
  floating,
  children,
}: Pick<
  React.ComponentProps<typeof Popover>,
  "open" | "defaultOpen" | "children"
> & {
  onOpenChange?: (open: boolean) => void
  floating?: boolean
}) {
  const isMobile = useIsMobile()
  const rootProps = {
    open,
    defaultOpen,
    onOpenChange: (next: boolean) => onOpenChange?.(next),
    children,
  }

  return (
    <FlyoutContext.Provider value={{ isMobile }}>
      {isMobile ? (
        <Drawer
          data-slot="flyout"
          floating={floating}
          showSwipeHandle
          {...rootProps}
        />
      ) : (
        <Popover data-slot="flyout" {...rootProps} />
      )}
    </FlyoutContext.Provider>
  )
}

function FlyoutTrigger(
  props: React.ButtonHTMLAttributes<HTMLButtonElement> & {
    render?: React.ReactElement
  }
) {
  const { isMobile } = useFlyout()
  const Trigger = isMobile ? DrawerTrigger : PopoverTrigger

  return <Trigger data-slot="flyout-trigger" {...props} />
}

function FlyoutContent({
  align,
  alignOffset,
  side,
  sideOffset,
  showCloseButton,
  closeLabel,
  ...props
}: React.HTMLAttributes<HTMLElement> &
  Pick<
    React.ComponentProps<typeof PopoverContent>,
    "align" | "alignOffset" | "side" | "sideOffset"
  > & {
    showCloseButton?: boolean
    closeLabel?: string
  }) {
  const { isMobile } = useFlyout()

  if (isMobile) {
    return (
      <DrawerContent
        data-slot="flyout-content"
        showCloseButton={showCloseButton}
        closeLabel={closeLabel}
        {...props}
      />
    )
  }

  return (
    <PopoverContent
      data-slot="flyout-content"
      align={align}
      alignOffset={alignOffset}
      side={side}
      sideOffset={sideOffset}
      {...props}
    />
  )
}

function FlyoutHeader(props: React.ComponentProps<"div">) {
  const { isMobile } = useFlyout()
  const Header = isMobile ? DrawerHeader : PopoverHeader

  return <Header data-slot="flyout-header" {...props} />
}

function FlyoutTitle(props: React.HTMLAttributes<HTMLHeadingElement>) {
  const { isMobile } = useFlyout()
  const Title = isMobile ? DrawerTitle : PopoverTitle

  return <Title data-slot="flyout-title" {...props} />
}

function FlyoutDescription(props: React.HTMLAttributes<HTMLParagraphElement>) {
  const { isMobile } = useFlyout()
  const Description = isMobile ? DrawerDescription : PopoverDescription

  return <Description data-slot="flyout-description" {...props} />
}

function FlyoutBody({ className, ...props }: React.ComponentProps<"div">) {
  const { isMobile } = useFlyout()

  return (
    <div
      data-slot="flyout-body"
      className={cn(isMobile && "p-4", className)}
      {...props}
    />
  )
}

function FlyoutClose(
  props: React.ButtonHTMLAttributes<HTMLButtonElement> & {
    render?: React.ReactElement
  }
) {
  const { isMobile } = useFlyout()
  const Close = isMobile ? DrawerClose : PopoverPrimitive.Close

  return <Close data-slot="flyout-close" {...props} />
}

export {
  Flyout,
  FlyoutBody,
  FlyoutClose,
  FlyoutContent,
  FlyoutDescription,
  FlyoutHeader,
  FlyoutTitle,
  FlyoutTrigger,
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { Flyout, FlyoutTrigger, FlyoutContent, FlyoutHeader, FlyoutTitle, FlyoutDescription, FlyoutBody, FlyoutClose } from "@/components/patterns/flyout"
```

```tsx
<div className="flex min-h-svh items-center justify-center">
  <Flyout>
    <FlyoutTrigger render={<Button variant="outline" />}>
      Dimensions
    </FlyoutTrigger>
    <FlyoutContent>
      <FlyoutHeader>
        <FlyoutTitle>Dimensions</FlyoutTitle>
        <FlyoutDescription>
          Set the dimensions for the layer.
        </FlyoutDescription>
      </FlyoutHeader>
      <FlyoutBody className="grid gap-3">
        <div className="grid grid-cols-3 items-center gap-3">
          <Label htmlFor="flyout-width">Width</Label>
          <Input
            id="flyout-width"
            defaultValue="100%"
            className="col-span-2"
          />
        </div>
        <div className="grid grid-cols-3 items-center gap-3">
          <Label htmlFor="flyout-height">Height</Label>
          <Input
            id="flyout-height"
            defaultValue="25px"
            className="col-span-2"
          />
        </div>
      </FlyoutBody>
    </FlyoutContent>
  </Flyout>
</div>
```

The flyout is uncontrolled with `defaultOpen`, or controlled with `open` and `onOpenChange`. `align`, `alignOffset`, `side` and `sideOffset` on `FlyoutContent` position the desktop popover and are ignored on mobile. Put interactive content in `FlyoutBody`, and use `FlyoutClose` for a button that closes the flyout after an action.

## Examples

### With close button

Adds an icon close button to the mobile drawer with `showCloseButton`.

**Example**

```tsx
import {
  Flyout,
  FlyoutBody,
  FlyoutContent,
  FlyoutDescription,
  FlyoutHeader,
  FlyoutTitle,
  FlyoutTrigger,
} from "@/components/patterns/flyout"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export default function FlyoutWithCloseButton() {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <Flyout>
        <FlyoutTrigger render={<Button variant="outline" />}>
          Filters
        </FlyoutTrigger>
        <FlyoutContent showCloseButton>
          <FlyoutHeader>
            <FlyoutTitle>Filters</FlyoutTitle>
            <FlyoutDescription>Narrow down the orders list.</FlyoutDescription>
          </FlyoutHeader>
          <FlyoutBody className="grid gap-2">
            <Label htmlFor="flyout-search">Customer</Label>
            <Input id="flyout-search" placeholder="Search by name" />
          </FlyoutBody>
        </FlyoutContent>
      </Flyout>
    </div>
  )
}
```

### With close label

Adds a text close button labelled Done to the mobile drawer with `closeLabel`.

**Example**

```tsx
import {
  Flyout,
  FlyoutBody,
  FlyoutContent,
  FlyoutDescription,
  FlyoutHeader,
  FlyoutTitle,
  FlyoutTrigger,
} from "@/components/patterns/flyout"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export default function FlyoutWithCloseLabel() {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <Flyout>
        <FlyoutTrigger render={<Button variant="outline" />}>
          Filters
        </FlyoutTrigger>
        <FlyoutContent showCloseButton closeLabel="Done">
          <FlyoutHeader>
            <FlyoutTitle>Filters</FlyoutTitle>
            <FlyoutDescription>Narrow down the orders list.</FlyoutDescription>
          </FlyoutHeader>
          <FlyoutBody className="grid gap-2">
            <Label htmlFor="flyout-search">Customer</Label>
            <Input id="flyout-search" placeholder="Search by name" />
          </FlyoutBody>
        </FlyoutContent>
      </Flyout>
    </div>
  )
}
```

### With close

Uses `FlyoutClose` on a Copy link button so the action also closes the flyout.

**Example**

```tsx
import {
  Flyout,
  FlyoutBody,
  FlyoutClose,
  FlyoutContent,
  FlyoutDescription,
  FlyoutHeader,
  FlyoutTitle,
  FlyoutTrigger,
} from "@/components/patterns/flyout"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

export default function FlyoutWithClose() {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <Flyout>
        <FlyoutTrigger render={<Button variant="outline" />}>
          Share
        </FlyoutTrigger>
        <FlyoutContent>
          <FlyoutHeader>
            <FlyoutTitle>Share link</FlyoutTitle>
            <FlyoutDescription>
              Anyone with the link can view this project.
            </FlyoutDescription>
          </FlyoutHeader>
          <FlyoutBody className="grid gap-3">
            <Input
              readOnly
              aria-label="Project link"
              defaultValue="https://example.com/p/1042"
            />
            <FlyoutClose render={<Button />}>Copy link</FlyoutClose>
          </FlyoutBody>
        </FlyoutContent>
      </Flyout>
    </div>
  )
}
```

### Floating

Insets the mobile drawer from the screen edges with `floating`.

**Example**

```tsx
import {
  Flyout,
  FlyoutBody,
  FlyoutContent,
  FlyoutDescription,
  FlyoutHeader,
  FlyoutTitle,
  FlyoutTrigger,
} from "@/components/patterns/flyout"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export default function FlyoutFloating() {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <Flyout floating>
        <FlyoutTrigger render={<Button variant="outline" />}>
          Dimensions
        </FlyoutTrigger>
        <FlyoutContent>
          <FlyoutHeader>
            <FlyoutTitle>Dimensions</FlyoutTitle>
            <FlyoutDescription>
              Set the dimensions for the layer.
            </FlyoutDescription>
          </FlyoutHeader>
          <FlyoutBody className="grid gap-3">
            <div className="grid grid-cols-3 items-center gap-3">
              <Label htmlFor="flyout-width">Width</Label>
              <Input
                id="flyout-width"
                defaultValue="100%"
                className="col-span-2"
              />
            </div>
            <div className="grid grid-cols-3 items-center gap-3">
              <Label htmlFor="flyout-height">Height</Label>
              <Input
                id="flyout-height"
                defaultValue="25px"
                className="col-span-2"
              />
            </div>
          </FlyoutBody>
        </FlyoutContent>
      </Flyout>
    </div>
  )
}
```

## API Reference

- `Flyout`
- `FlyoutTrigger`
- `FlyoutContent`
  - `FlyoutHeader`
    - `FlyoutTitle`
    - `FlyoutDescription`
  - `FlyoutBody`
    - `FlyoutClose`

### Flyout

The root that renders a `Popover` on desktop and a `Drawer` on mobile.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onOpenChange` | `(open: boolean) => void` | - | Called when the flyout opens or closes. |
| `floating` | `boolean` | - | Insets the mobile drawer from the screen edges with fully rounded corners. |

Also accepts the props of `React.ComponentProps<typeof Popover>`.

### FlyoutTrigger

The button that opens the flyout.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `React.ReactElement` | - | Replaces the default button element with your own element, such as a `Button`. |

Also accepts the props of `React.ButtonHTMLAttributes<HTMLButtonElement>`.

### FlyoutContent

The popup; a `PopoverContent` on desktop and a `DrawerContent` on mobile.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `showCloseButton` | `boolean` | - | Shows a close button in the mobile drawer. |
| `closeLabel` | `string` | - | Replaces the drawer's close icon with this text; requires `showCloseButton`. |

Also accepts the props of `React.HTMLAttributes<HTMLElement>`, `React.ComponentProps<typeof PopoverContent>`.

### FlyoutHeader

Groups the title and description.

Also accepts the props of `React.ComponentProps<"div">`.

### FlyoutTitle

The flyout's title, used as its accessible name.

Also accepts the props of `React.HTMLAttributes<HTMLHeadingElement>`.

### FlyoutDescription

Supporting text, used as the flyout's accessible description.

Also accepts the props of `React.HTMLAttributes<HTMLParagraphElement>`.

### FlyoutBody

The main content area, padded on mobile.

Also accepts the props of `React.ComponentProps<"div">`.

### FlyoutClose

A button that closes the flyout.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `React.ReactElement` | - | Replaces the default button element with your own element, such as a `Button`. |

Also accepts the props of `React.ButtonHTMLAttributes<HTMLButtonElement>`.

## Accessibility

- The popup has the `dialog` role, named by `FlyoutTitle` and described by `FlyoutDescription`.
- Opening moves focus into the flyout.
- `Escape` closes it and returns focus to the trigger; pressing outside also closes it.

## Notes

Parts carry `data-slot` values from `flyout` through `flyout-close`. `showCloseButton` and `closeLabel` only affect the mobile drawer; on desktop, add a `FlyoutClose` if the popover needs an explicit close control.

