suiss/ui
  • Docs
  • Components
  • Patterns
  • Interactions
  • Charts
  • Blocks
GitHubGet started
DocsComponentsPatternsInteractionsChartsBlocks
Patterns19
  • Overview
  • Action Bar
  • Action Menu
  • App Shell
  • Command Palette
  • Confirm
  • Context Actions
  • Date Picker
  • Flyout
  • Modal
  • Navbar
  • Paged List
  • Panel
  • Picker
  • Preview
  • Search Picker
  • Segment Picker
  • Split View
  • Table List
  • Trail

suiss/ui · Pattern

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.

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

$ pnpm dlx shadcn@latest add @esuiss/panel

Works in any project with a components.json. See Installation for the one-line registry setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add patterns panel

Manual

  1. Add the cn helper.

  2. Add the pieces it builds on: Drawer, Sheet, use-mobile.

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

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,
  1. Update the import paths to match your project.

Usage

import { Panel, PanelTrigger, PanelContent, PanelHeader, PanelTitle, PanelDescription, PanelBody, PanelFooter, PanelClose } from "@/components/patterns/panel"
<div className="flex min-h-svh items-center justify-center">
  <Panel>
    <PanelTrigger render={<Button variant="outline" />}>
      Edit profile
    </PanelTrigger>
    <ProfilePanel />
  </Panel>
</div>
<div className="flex min-h-svh items-center justify-center">
  <Panel>
    <PanelTrigger asChild>
      <Button variant="outline">Edit profile</Button>
    </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.
  • Open state is uncontrolled by default; use defaultOpen for an initial state, or open with onOpenChange to control it.
  • Set asChild on PanelTrigger and PanelClose to render a suiss/ui Button in their place.

Examples

By default

Starts open with defaultOpen and closes from the close button.

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.

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.

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.

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.

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

Anatomy2 parts
<Panel>
<PanelTrigger />
</Panel>
01

Panel

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

PropTypeDefaultDescription
onOpenChange(open: boolean) => void-Called with the new open state when the panel opens or closes.
floatingboolean-Insets the mobile drawer from the screen edges. Has no effect on desktop.

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

02

PanelTrigger

The button that opens the panel.

PropTypeDefaultDescription
renderReact.ReactElement-Replaces the rendered element, for example with a Button.

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

03

PanelContent

The popup surface, with an optional close button.

PropTypeDefaultDescription
side"top" | "right" | "bottom" | "left""right"The screen edge the desktop sheet slides in from. Ignored on mobile.
showCloseButtonbooleantrueShows the built-in close button.
closeLabelstring-Text for the close button in place of the icon.

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

04

PanelHeader

Groups the title and description.

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

05

PanelTitle

The panel's heading, which names the dialog.

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

06

PanelDescription

Supporting text that describes the dialog.

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

07

PanelBody

The padded main content area.

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

08

PanelFooter

Holds the panel's actions.

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

09

PanelClose

A button that closes the panel.

PropTypeDefaultDescription
renderReact.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.

NextPickerPreviousPaged List

On this page

  1. 01Installation
    • CLI
    • Manual
  2. 02Usage
  3. 03Examples
    • By default
    • Sides
    • With close label
    • Without close button
    • Floating
  4. 04API Reference
    • Panel
    • PanelTrigger
    • PanelContent
    • PanelHeader
    • PanelTitle
    • PanelDescription
    • PanelBody
    • PanelFooter
    • PanelClose
  5. 05Accessibility
  6. 06Notes