# Panel

A side panel that renders as a Sheet on desktop and a swipeable Drawer on mobile.

Panel 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/panel

**React**

**Example**

```tsx
import {
  Panel,
  PanelBody,
  PanelClose,
  PanelContent,
  PanelDescription,
  PanelFooter,
  PanelHeader,
  PanelTitle,
  PanelTrigger,
} from "@/components/patterns/panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

type Side = "top" | "right" | "bottom" | "left"

function ProfilePanel({
  side,
  showCloseButton,
  closeLabel,
}: {
  side?: Side
  showCloseButton?: boolean
  closeLabel?: string
}) {
  return (
    <PanelContent
      side={side}
      showCloseButton={showCloseButton}
      closeLabel={closeLabel}
    >
      <PanelHeader>
        <PanelTitle>Edit profile</PanelTitle>
        <PanelDescription>
          Update your details and save when you are done.
        </PanelDescription>
      </PanelHeader>
      <PanelBody className="grid gap-4">
        <div className="grid gap-2">
          <Label htmlFor="panel-name">Name</Label>
          <Input id="panel-name" defaultValue="Jordan Lee" />
        </div>
        <div className="grid gap-2">
          <Label htmlFor="panel-username">Username</Label>
          <Input id="panel-username" defaultValue="@jordanlee" />
        </div>
      </PanelBody>
      <PanelFooter>
        <Button>Save changes</Button>
        <PanelClose render={<Button variant="secondary" />}>Cancel</PanelClose>
      </PanelFooter>
    </PanelContent>
  )
}

export default function PanelDemo() {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <Panel>
        <PanelTrigger render={<Button variant="outline" />}>
          Edit profile
        </PanelTrigger>
        <ProfilePanel />
      </Panel>
    </div>
  )
}
```

`Panel` presents secondary content such as forms, filters or details without leaving the page. On desktop it renders a suiss/ui `Sheet` that slides in from the chosen side. On mobile (below `48rem`, via `useIsMobile`) it switches to a `Drawer` with a swipe handle, and every subcomponent swaps to its Drawer counterpart.

The parts mirror the Sheet and Drawer anatomy: a trigger, content with an optional close button, header, title, description, body, footer and a close control. Use it where a desktop layout calls for a side sheet and a phone calls for a bottom sheet.

## Installation

### CLI

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

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 panel
```

### Manual

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

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

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

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

import * as React from "react"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"

type PanelContextProps = {
  isMobile: boolean
}

const PanelContext = React.createContext<PanelContextProps | null>(null)

function usePanel() {
  const context = React.useContext(PanelContext)

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

  return context
}

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

  return (
    <PanelContext.Provider value={{ isMobile }}>
      {isMobile ? (
        <Drawer
          data-slot="panel"
          floating={floating}
          showSwipeHandle
          {...rootProps}
        />
      ) : (
        <Sheet data-slot="panel" {...rootProps} />
      )}
    </PanelContext.Provider>
  )
}

function PanelTrigger(
  props: React.ButtonHTMLAttributes<HTMLButtonElement> & {
    render?: React.ReactElement
  }
) {
  const { isMobile } = usePanel()
  const Trigger = isMobile ? DrawerTrigger : SheetTrigger

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

function PanelContent({
  side = "right",
  showCloseButton = true,
  closeLabel,
  ...props
}: React.HTMLAttributes<HTMLElement> & {
  side?: "top" | "right" | "bottom" | "left"
  showCloseButton?: boolean
  closeLabel?: string
}) {
  const { isMobile } = usePanel()

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

  return (
    <SheetContent
      data-slot="panel-content"
      side={side}
      showCloseButton={showCloseButton}
      closeLabel={closeLabel}
      {...props}
    />
  )
}

function PanelHeader(props: React.ComponentProps<"div">) {
  const { isMobile } = usePanel()
  const Header = isMobile ? DrawerHeader : SheetHeader

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

function PanelTitle(props: React.HTMLAttributes<HTMLHeadingElement>) {
  const { isMobile } = usePanel()
  const Title = isMobile ? DrawerTitle : SheetTitle

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

function PanelDescription(props: React.HTMLAttributes<HTMLParagraphElement>) {
  const { isMobile } = usePanel()
  const Description = isMobile ? DrawerDescription : SheetDescription

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

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

  return (
    <div
      data-slot="panel-body"
      className={cn(isMobile ? "p-4" : "px-6", className)}
      {...props}
    />
  )
}

function PanelFooter(props: React.ComponentProps<"div">) {
  const { isMobile } = usePanel()
  const Footer = isMobile ? DrawerFooter : SheetFooter

  return <Footer data-slot="panel-footer" {...props} />
}

function PanelClose(
  props: React.ButtonHTMLAttributes<HTMLButtonElement> & {
    render?: React.ReactElement
  }
) {
  const { isMobile } = usePanel()
  const Close = isMobile ? DrawerClose : SheetClose

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

export {
  Panel,
  PanelBody,
  PanelClose,
  PanelContent,
  PanelDescription,
  PanelFooter,
  PanelHeader,
  PanelTitle,
  PanelTrigger,
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { Panel, PanelTrigger, PanelContent, PanelHeader, PanelTitle, PanelDescription, PanelBody, PanelFooter, PanelClose } from "@/components/patterns/panel"
```

```tsx
<div className="flex min-h-svh items-center justify-center">
  <Panel>
    <PanelTrigger render={<Button variant="outline" />}>
      Edit profile
    </PanelTrigger>
    <ProfilePanel />
  </Panel>
</div>
```

- Compose `PanelTrigger` and `PanelContent` inside `Panel`. All parts must be used inside `Panel`, or they throw.
- Put scrollable or form content in `PanelBody`, which applies padding that matches each presentation.

- Open state is uncontrolled by default; use `defaultOpen` for an initial state, or `open` with `onOpenChange` to control it.
- Use the `render` prop on `PanelTrigger` and `PanelClose` to render them as a suiss/ui `Button`.

## Examples

### By default

Starts open with `defaultOpen` and closes from the close button.

**Example**

```tsx
import {
  Panel,
  PanelBody,
  PanelClose,
  PanelContent,
  PanelDescription,
  PanelFooter,
  PanelHeader,
  PanelTitle,
  PanelTrigger,
} from "@/components/patterns/panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

type Side = "top" | "right" | "bottom" | "left"

function ProfilePanel({
  side,
  showCloseButton,
  closeLabel,
}: {
  side?: Side
  showCloseButton?: boolean
  closeLabel?: string
}) {
  return (
    <PanelContent
      side={side}
      showCloseButton={showCloseButton}
      closeLabel={closeLabel}
    >
      <PanelHeader>
        <PanelTitle>Edit profile</PanelTitle>
        <PanelDescription>
          Update your details and save when you are done.
        </PanelDescription>
      </PanelHeader>
      <PanelBody className="grid gap-4">
        <div className="grid gap-2">
          <Label htmlFor="panel-name">Name</Label>
          <Input id="panel-name" defaultValue="Jordan Lee" />
        </div>
        <div className="grid gap-2">
          <Label htmlFor="panel-username">Username</Label>
          <Input id="panel-username" defaultValue="@jordanlee" />
        </div>
      </PanelBody>
      <PanelFooter>
        <Button>Save changes</Button>
        <PanelClose render={<Button variant="secondary" />}>Cancel</PanelClose>
      </PanelFooter>
    </PanelContent>
  )
}

export default function PanelOpenByDefault() {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <Panel defaultOpen>
        <PanelTrigger render={<Button variant="outline" />}>
          Edit profile
        </PanelTrigger>
        <ProfilePanel />
      </Panel>
    </div>
  )
}
```

### Sides

Opens the desktop sheet from the top, right, bottom and left.

**Example**

```tsx
import {
  Panel,
  PanelBody,
  PanelClose,
  PanelContent,
  PanelDescription,
  PanelFooter,
  PanelHeader,
  PanelTitle,
  PanelTrigger,
} from "@/components/patterns/panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

type Side = "top" | "right" | "bottom" | "left"

function ProfilePanel({
  side,
  showCloseButton,
  closeLabel,
}: {
  side?: Side
  showCloseButton?: boolean
  closeLabel?: string
}) {
  return (
    <PanelContent
      side={side}
      showCloseButton={showCloseButton}
      closeLabel={closeLabel}
    >
      <PanelHeader>
        <PanelTitle>Edit profile</PanelTitle>
        <PanelDescription>
          Update your details and save when you are done.
        </PanelDescription>
      </PanelHeader>
      <PanelBody className="grid gap-4">
        <div className="grid gap-2">
          <Label htmlFor="panel-name">Name</Label>
          <Input id="panel-name" defaultValue="Jordan Lee" />
        </div>
        <div className="grid gap-2">
          <Label htmlFor="panel-username">Username</Label>
          <Input id="panel-username" defaultValue="@jordanlee" />
        </div>
      </PanelBody>
      <PanelFooter>
        <Button>Save changes</Button>
        <PanelClose render={<Button variant="secondary" />}>Cancel</PanelClose>
      </PanelFooter>
    </PanelContent>
  )
}

export default function PanelSides() {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <div className="flex flex-wrap items-center gap-3">
        {(["top", "right", "bottom", "left"] as const).map((side) => (
          <Panel key={side}>
            <PanelTrigger render={<Button variant="outline" />}>
              {side.charAt(0).toUpperCase() + side.slice(1)}
            </PanelTrigger>
            <ProfilePanel side={side} />
          </Panel>
        ))}
      </div>
    </div>
  )
}
```

### With close label

Replaces the close icon with a Done text button through `closeLabel`.

**Example**

```tsx
import {
  Panel,
  PanelBody,
  PanelClose,
  PanelContent,
  PanelDescription,
  PanelFooter,
  PanelHeader,
  PanelTitle,
  PanelTrigger,
} from "@/components/patterns/panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

type Side = "top" | "right" | "bottom" | "left"

function ProfilePanel({
  side,
  showCloseButton,
  closeLabel,
}: {
  side?: Side
  showCloseButton?: boolean
  closeLabel?: string
}) {
  return (
    <PanelContent
      side={side}
      showCloseButton={showCloseButton}
      closeLabel={closeLabel}
    >
      <PanelHeader>
        <PanelTitle>Edit profile</PanelTitle>
        <PanelDescription>
          Update your details and save when you are done.
        </PanelDescription>
      </PanelHeader>
      <PanelBody className="grid gap-4">
        <div className="grid gap-2">
          <Label htmlFor="panel-name">Name</Label>
          <Input id="panel-name" defaultValue="Jordan Lee" />
        </div>
        <div className="grid gap-2">
          <Label htmlFor="panel-username">Username</Label>
          <Input id="panel-username" defaultValue="@jordanlee" />
        </div>
      </PanelBody>
      <PanelFooter>
        <Button>Save changes</Button>
        <PanelClose render={<Button variant="secondary" />}>Cancel</PanelClose>
      </PanelFooter>
    </PanelContent>
  )
}

export default function PanelWithCloseLabel() {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <Panel>
        <PanelTrigger render={<Button variant="outline" />}>
          Edit profile
        </PanelTrigger>
        <ProfilePanel closeLabel="Done" />
      </Panel>
    </div>
  )
}
```

### Without close button

Hides the built-in close button and closes from a Cancel button instead.

**Example**

```tsx
import {
  Panel,
  PanelBody,
  PanelClose,
  PanelContent,
  PanelDescription,
  PanelFooter,
  PanelHeader,
  PanelTitle,
  PanelTrigger,
} from "@/components/patterns/panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

type Side = "top" | "right" | "bottom" | "left"

function ProfilePanel({
  side,
  showCloseButton,
  closeLabel,
}: {
  side?: Side
  showCloseButton?: boolean
  closeLabel?: string
}) {
  return (
    <PanelContent
      side={side}
      showCloseButton={showCloseButton}
      closeLabel={closeLabel}
    >
      <PanelHeader>
        <PanelTitle>Edit profile</PanelTitle>
        <PanelDescription>
          Update your details and save when you are done.
        </PanelDescription>
      </PanelHeader>
      <PanelBody className="grid gap-4">
        <div className="grid gap-2">
          <Label htmlFor="panel-name">Name</Label>
          <Input id="panel-name" defaultValue="Jordan Lee" />
        </div>
        <div className="grid gap-2">
          <Label htmlFor="panel-username">Username</Label>
          <Input id="panel-username" defaultValue="@jordanlee" />
        </div>
      </PanelBody>
      <PanelFooter>
        <Button>Save changes</Button>
        <PanelClose render={<Button variant="secondary" />}>Cancel</PanelClose>
      </PanelFooter>
    </PanelContent>
  )
}

export default function PanelWithoutCloseButton() {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <Panel>
        <PanelTrigger render={<Button variant="outline" />}>
          Edit profile
        </PanelTrigger>
        <ProfilePanel showCloseButton={false} />
      </Panel>
    </div>
  )
}
```

### Floating

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

**Example**

```tsx
import {
  Panel,
  PanelBody,
  PanelClose,
  PanelContent,
  PanelDescription,
  PanelFooter,
  PanelHeader,
  PanelTitle,
  PanelTrigger,
} from "@/components/patterns/panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

type Side = "top" | "right" | "bottom" | "left"

function ProfilePanel({
  side,
  showCloseButton,
  closeLabel,
}: {
  side?: Side
  showCloseButton?: boolean
  closeLabel?: string
}) {
  return (
    <PanelContent
      side={side}
      showCloseButton={showCloseButton}
      closeLabel={closeLabel}
    >
      <PanelHeader>
        <PanelTitle>Edit profile</PanelTitle>
        <PanelDescription>
          Update your details and save when you are done.
        </PanelDescription>
      </PanelHeader>
      <PanelBody className="grid gap-4">
        <div className="grid gap-2">
          <Label htmlFor="panel-name">Name</Label>
          <Input id="panel-name" defaultValue="Jordan Lee" />
        </div>
        <div className="grid gap-2">
          <Label htmlFor="panel-username">Username</Label>
          <Input id="panel-username" defaultValue="@jordanlee" />
        </div>
      </PanelBody>
      <PanelFooter>
        <Button>Save changes</Button>
        <PanelClose render={<Button variant="secondary" />}>Cancel</PanelClose>
      </PanelFooter>
    </PanelContent>
  )
}

export default function PanelFloating() {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <Panel floating>
        <PanelTrigger render={<Button variant="outline" />}>
          Edit profile
        </PanelTrigger>
        <ProfilePanel />
      </Panel>
    </div>
  )
}
```

## API Reference

- `Panel`
- `PanelTrigger`

### Panel

The root that chooses Sheet or Drawer and holds the open state.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onOpenChange` | `(open: boolean) => void` | - | Called with the new open state when the panel opens or closes. |
| `floating` | `boolean` | - | Insets the mobile drawer from the screen edges. Has no effect on desktop. |

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

### PanelTrigger

The button that opens the panel.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `React.ReactElement` | - | Replaces the rendered element, for example with a `Button`. |

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

### PanelContent

The popup surface, with an optional close button.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `side` | `"top" \| "right" \| "bottom" \| "left"` | `"right"` | The screen edge the desktop sheet slides in from. Ignored on mobile. |
| `showCloseButton` | `boolean` | `true` | Shows the built-in close button. |
| `closeLabel` | `string` | - | Text for the close button in place of the icon. |

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

### PanelHeader

Groups the title and description.

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

### PanelTitle

The panel's heading, which names the dialog.

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

### PanelDescription

Supporting text that describes the dialog.

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

### PanelBody

The padded main content area.

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

### PanelFooter

Holds the panel's actions.

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

### PanelClose

A button that closes the panel.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `React.ReactElement` | - | Replaces the rendered element, for example with a `Button`. |

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

## Accessibility

- The content is a `dialog` named by `PanelTitle` and described by `PanelDescription`.
- Escape closes the panel and returns focus to the trigger.
- The built-in close button is named Close, or by `closeLabel` when set.

## Notes

Styling hooks: `data-slot="panel"`, `panel-trigger`, `panel-content`, `panel-header`, `panel-title`, `panel-description`, `panel-body`, `panel-footer` and `panel-close`. The presentation follows the viewport at render time, so a panel open while the viewport crosses `48rem` re-renders in the other presentation.

