# Sheet

A panel that slides in from an edge of the screen over the page.

Sheet is an open-source component from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/components/sheet

**React**

**Example**

```tsx
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

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

function ProfileSheet({
  side = "right",
  showCloseButton = true,
  closeLabel,
}: {
  side?: Side
  showCloseButton?: boolean
  closeLabel?: string
}) {
  return (
    <SheetContent
      side={side}
      showCloseButton={showCloseButton}
      closeLabel={closeLabel}
    >
      <SheetHeader>
        <SheetTitle>Edit profile</SheetTitle>
        <SheetDescription>
          Update your details and save when you are done.
        </SheetDescription>
      </SheetHeader>
      <div className="grid gap-4 px-6">
        <div className="grid gap-2">
          <Label htmlFor="sheet-name">Name</Label>
          <Input id="sheet-name" defaultValue="Jordan Lee" />
        </div>
        <div className="grid gap-2">
          <Label htmlFor="sheet-username">Username</Label>
          <Input id="sheet-username" defaultValue="@jordanlee" />
        </div>
      </div>
      <SheetFooter>
        <Button type="submit">Save changes</Button>
        <SheetClose render={<Button variant="secondary" />}>Cancel</SheetClose>
      </SheetFooter>
    </SheetContent>
  )
}

export default function SheetDemo() {
  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>
        Open sheet
      </SheetTrigger>
      <ProfileSheet />
    </Sheet>
  )
}
```

`Sheet` is built on the dialog primitive of the chosen base. `SheetTrigger` opens it, and `SheetContent` renders the panel in a portal over a dimming backdrop. Inside, `SheetHeader` holds a `SheetTitle` and `SheetDescription`, and `SheetFooter` holds actions. `SheetClose` closes the sheet from any element.

The panel floats inset from the screen edge with rounded corners and slides in from its `side`. Use a sheet for supporting tasks, such as editing details or showing navigation, that should keep the page in context. For short, centered confirmations, use `Dialog` or `AlertDialog`.

## Installation

### CLI

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

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

### 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 @phosphor-icons/react
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), `use-scrollbar-gap`.

3. Copy the source into `components/ui/sheet.tsx`:

```tsx title="components/ui/sheet.tsx"
"use client"

import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { XIcon } from "@phosphor-icons/react"
import type * as React from "react"
import { Button } from "@/components/ui/button"
import { useScrollbarGap } from "@/hooks/use-scrollbar-gap"
import { cn } from "@/lib/utils"

function Sheet({ ...props }: SheetPrimitive.Root.Props) {
  return <SheetPrimitive.Root data-slot="sheet" {...props} />
}

function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
  return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}

function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
  return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}

function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
  return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}

function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
  useScrollbarGap()

  return (
    <SheetPrimitive.Backdrop
      data-slot="sheet-overlay"
      className={cn(
        "fixed inset-0 z-50 bg-black/50 transition-opacity duration-400 data-ending-style:opacity-0 data-starting-style:opacity-0",
        className
      )}
      {...props}
    />
  )
}

function SheetContent({
  className,
  children,
  side = "right",
  showCloseButton = true,
  closeLabel,
  ...props
}: SheetPrimitive.Popup.Props & {
  side?: "top" | "right" | "bottom" | "left"
  showCloseButton?: boolean
  closeLabel?: string
}) {
  return (
    <SheetPortal>
      <SheetOverlay />
      <SheetPrimitive.Popup
        data-slot="sheet-content"
        data-side={side}
        className={cn(
          "has-[>[data-slot=sheet-close]]:*:first:pe-14 has-[>[data-slot=sheet-close][data-label]]:*:first:pe-24 fixed z-50 flex flex-col rounded-3xl bg-popover text-base text-popover-foreground shadow-xl transition duration-400 ease-[cubic-bezier(0.32,0.72,0,1)] data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-2 data-[side=bottom]:bottom-2 data-[side=bottom]:h-auto data-[side=bottom]:data-ending-style:translate-y-10 data-[side=bottom]:data-starting-style:translate-y-10 data-[side=left]:inset-y-2 data-[side=left]:left-2 data-[side=left]:w-3/4 data-[side=left]:data-ending-style:-translate-x-10 data-[side=left]:data-starting-style:-translate-x-10 data-[side=right]:inset-y-2 data-[side=right]:right-2 data-[side=right]:w-3/4 data-[side=right]:data-ending-style:translate-x-10 data-[side=right]:data-starting-style:translate-x-10 data-[side=top]:inset-x-2 data-[side=top]:top-2 data-[side=top]:h-auto data-[side=top]:data-ending-style:-translate-y-10 data-[side=top]:data-starting-style:-translate-y-10 data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
          className
        )}
        {...props}
      >
        {children}
        {showCloseButton && (
          <SheetPrimitive.Close
            data-slot="sheet-close"
            data-label={closeLabel ? "" : undefined}
            render={
              <Button
                variant={closeLabel ? "plain" : "ghost"}
                className={cn(
                  "absolute top-4.75 inset-e-4",
                  closeLabel && "text-base",
                  !closeLabel &&
                    "bg-secondary hover:bg-[color-mix(in_oklab,var(--secondary),var(--foreground)_5%)]"
                )}
                size={closeLabel ? "sm" : "icon-sm"}
              />
            }
          >
            {closeLabel ?? (
              <>
                <XIcon />
                <span className="sr-only">Close</span>
              </>
            )}
          </SheetPrimitive.Close>
        )}
      </SheetPrimitive.Popup>
    </SheetPortal>
  )
}

function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="sheet-header"
      className={cn("flex flex-col gap-1.5 p-6", className)}
      {...props}
    />
  )
}

function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="sheet-footer"
      className={cn("mt-auto flex flex-col gap-2 p-6", className)}
      {...props}
    />
  )
}

function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
  return (
    <SheetPrimitive.Title
      data-slot="sheet-title"
      className={cn("text-base font-semibold text-foreground", className)}
      {...props}
    />
  )
}

function SheetDescription({
  className,
  ...props
}: SheetPrimitive.Description.Props) {
  return (
    <SheetPrimitive.Description
      data-slot="sheet-description"
      className={cn("text-sm text-muted-foreground", className)}
      {...props}
    />
  )
}

export {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription } from "@/components/ui/sheet"
```

```tsx
<Sheet>
  <SheetTrigger render={<Button variant="outline" />}>
    Open sheet
  </SheetTrigger>
  <ProfileSheet />
</Sheet>
```

Use `defaultOpen` for an uncontrolled sheet, or `open` with `onOpenChange` to control it. Use the `render` prop on `SheetTrigger` and `SheetClose` to render them as a `Button`.

`SheetFooter` sits at the bottom of the panel with `mt-auto`. By default `SheetContent` adds an icon close button in the top corner; set `closeLabel` to show a text button instead, or `showCloseButton={false}` to remove it.

## Examples

### Sides

Opens a sheet from each of the four edges.

**Example**

```tsx
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

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

function ProfileSheet({
  side = "right",
  showCloseButton = true,
  closeLabel,
}: {
  side?: Side
  showCloseButton?: boolean
  closeLabel?: string
}) {
  return (
    <SheetContent
      side={side}
      showCloseButton={showCloseButton}
      closeLabel={closeLabel}
    >
      <SheetHeader>
        <SheetTitle>Edit profile</SheetTitle>
        <SheetDescription>
          Update your details and save when you are done.
        </SheetDescription>
      </SheetHeader>
      <div className="grid gap-4 px-6">
        <div className="grid gap-2">
          <Label htmlFor="sheet-name">Name</Label>
          <Input id="sheet-name" defaultValue="Jordan Lee" />
        </div>
        <div className="grid gap-2">
          <Label htmlFor="sheet-username">Username</Label>
          <Input id="sheet-username" defaultValue="@jordanlee" />
        </div>
      </div>
      <SheetFooter>
        <Button type="submit">Save changes</Button>
        <SheetClose render={<Button variant="secondary" />}>Cancel</SheetClose>
      </SheetFooter>
    </SheetContent>
  )
}

export default function SheetSides() {
  return (
    <div className="grid grid-cols-2 gap-3">
      {(["top", "right", "bottom", "left"] as const).map((side) => (
        <Sheet key={side}>
          <SheetTrigger render={<Button variant="outline" />}>
            {side.charAt(0).toUpperCase() + side.slice(1)}
          </SheetTrigger>
          <ProfileSheet side={side} />
        </Sheet>
      ))}
    </div>
  )
}
```

### Left

Renders a sheet open on the left side.

**Example**

```tsx
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

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

function ProfileSheet({
  side = "right",
  showCloseButton = true,
  closeLabel,
}: {
  side?: Side
  showCloseButton?: boolean
  closeLabel?: string
}) {
  return (
    <SheetContent
      side={side}
      showCloseButton={showCloseButton}
      closeLabel={closeLabel}
    >
      <SheetHeader>
        <SheetTitle>Edit profile</SheetTitle>
        <SheetDescription>
          Update your details and save when you are done.
        </SheetDescription>
      </SheetHeader>
      <div className="grid gap-4 px-6">
        <div className="grid gap-2">
          <Label htmlFor="sheet-name">Name</Label>
          <Input id="sheet-name" defaultValue="Jordan Lee" />
        </div>
        <div className="grid gap-2">
          <Label htmlFor="sheet-username">Username</Label>
          <Input id="sheet-username" defaultValue="@jordanlee" />
        </div>
      </div>
      <SheetFooter>
        <Button type="submit">Save changes</Button>
        <SheetClose render={<Button variant="secondary" />}>Cancel</SheetClose>
      </SheetFooter>
    </SheetContent>
  )
}

export default function SheetOpenLeft() {
  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>
        Open sheet
      </SheetTrigger>
      <ProfileSheet side="left" />
    </Sheet>
  )
}
```

### Bottom

Renders a sheet open along the bottom edge.

**Example**

```tsx
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

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

function ProfileSheet({
  side = "right",
  showCloseButton = true,
  closeLabel,
}: {
  side?: Side
  showCloseButton?: boolean
  closeLabel?: string
}) {
  return (
    <SheetContent
      side={side}
      showCloseButton={showCloseButton}
      closeLabel={closeLabel}
    >
      <SheetHeader>
        <SheetTitle>Edit profile</SheetTitle>
        <SheetDescription>
          Update your details and save when you are done.
        </SheetDescription>
      </SheetHeader>
      <div className="grid gap-4 px-6">
        <div className="grid gap-2">
          <Label htmlFor="sheet-name">Name</Label>
          <Input id="sheet-name" defaultValue="Jordan Lee" />
        </div>
        <div className="grid gap-2">
          <Label htmlFor="sheet-username">Username</Label>
          <Input id="sheet-username" defaultValue="@jordanlee" />
        </div>
      </div>
      <SheetFooter>
        <Button type="submit">Save changes</Button>
        <SheetClose render={<Button variant="secondary" />}>Cancel</SheetClose>
      </SheetFooter>
    </SheetContent>
  )
}

export default function SheetOpenBottom() {
  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>
        Open sheet
      </SheetTrigger>
      <ProfileSheet side="bottom" />
    </Sheet>
  )
}
```

### Without close button

Removes the built-in close button.

**Example**

```tsx
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

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

function ProfileSheet({
  side = "right",
  showCloseButton = true,
  closeLabel,
}: {
  side?: Side
  showCloseButton?: boolean
  closeLabel?: string
}) {
  return (
    <SheetContent
      side={side}
      showCloseButton={showCloseButton}
      closeLabel={closeLabel}
    >
      <SheetHeader>
        <SheetTitle>Edit profile</SheetTitle>
        <SheetDescription>
          Update your details and save when you are done.
        </SheetDescription>
      </SheetHeader>
      <div className="grid gap-4 px-6">
        <div className="grid gap-2">
          <Label htmlFor="sheet-name">Name</Label>
          <Input id="sheet-name" defaultValue="Jordan Lee" />
        </div>
        <div className="grid gap-2">
          <Label htmlFor="sheet-username">Username</Label>
          <Input id="sheet-username" defaultValue="@jordanlee" />
        </div>
      </div>
      <SheetFooter>
        <Button type="submit">Save changes</Button>
        <SheetClose render={<Button variant="secondary" />}>Cancel</SheetClose>
      </SheetFooter>
    </SheetContent>
  )
}

export default function SheetWithoutCloseButton() {
  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>
        Open sheet
      </SheetTrigger>
      <ProfileSheet showCloseButton={false} />
    </Sheet>
  )
}
```

### With close label

Replaces the icon close button with a text button labelled `Done`.

**Example**

```tsx
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

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

function ProfileSheet({
  side = "right",
  showCloseButton = true,
  closeLabel,
}: {
  side?: Side
  showCloseButton?: boolean
  closeLabel?: string
}) {
  return (
    <SheetContent
      side={side}
      showCloseButton={showCloseButton}
      closeLabel={closeLabel}
    >
      <SheetHeader>
        <SheetTitle>Edit profile</SheetTitle>
        <SheetDescription>
          Update your details and save when you are done.
        </SheetDescription>
      </SheetHeader>
      <div className="grid gap-4 px-6">
        <div className="grid gap-2">
          <Label htmlFor="sheet-name">Name</Label>
          <Input id="sheet-name" defaultValue="Jordan Lee" />
        </div>
        <div className="grid gap-2">
          <Label htmlFor="sheet-username">Username</Label>
          <Input id="sheet-username" defaultValue="@jordanlee" />
        </div>
      </div>
      <SheetFooter>
        <Button type="submit">Save changes</Button>
        <SheetClose render={<Button variant="secondary" />}>Cancel</SheetClose>
      </SheetFooter>
    </SheetContent>
  )
}

export default function SheetWithCloseLabel() {
  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>
        Open sheet
      </SheetTrigger>
      <ProfileSheet closeLabel="Done" />
    </Sheet>
  )
}
```

## API Reference

- `Sheet`
- `SheetTrigger`

### Sheet

The root that holds the open state.

Also accepts the props of the primitive part it wraps.

### SheetTrigger

The element that opens the sheet.

Also accepts the props of the primitive part it wraps.

### SheetClose

An element that closes the sheet when pressed.

Also accepts the props of the primitive part it wraps.

### SheetContent

The sliding panel, rendered in a portal with a backdrop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `side` | `"top" \| "right" \| "bottom" \| "left"` | `"right"` | Chooses the edge the sheet slides in from. |
| `showCloseButton` | `boolean` | `true` | Shows the built-in close button in the top corner. |
| `closeLabel` | `string` | - | Shows this text in the close button instead of an icon. |

Also accepts the props of the primitive part it wraps.

### SheetHeader

A padded stack for the title and description.

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

### SheetFooter

A padded stack for actions, pushed to the bottom of the panel.

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

### SheetTitle

The sheet's title, which names the dialog.

Also accepts the props of the primitive part it wraps.

### SheetDescription

Supporting text, which describes the dialog.

Also accepts the props of the primitive part it wraps.

## Accessibility

- The panel has the `dialog` role. `SheetTitle` gives it an accessible name and `SheetDescription` an accessible description.
- Opening the sheet moves focus inside it.
- `Escape` closes the sheet, and focus returns to the trigger.
- The icon close button has the hidden label `Close`.

## Notes

- Parts carry `data-slot` attributes such as `sheet-content`, `sheet-overlay` and `sheet-close`; the panel also sets `data-side`.
- Left and right sheets are 3/4 of the screen wide, up to `max-w-sm` from the `sm` breakpoint. Slide directions flip in right-to-left layouts.
- The panel uses `bg-popover` and the backdrop uses `bg-black/50`.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/components-sheet--open-by-default.light.webp)

```tsx
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { Text } from "@/components/ui/text"

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

function ProfileContent({ side }: { side?: Side }) {
  return (
    <SheetContent side={side}>
      <SheetHeader>
        <SheetTitle>Edit profile</SheetTitle>
        <SheetDescription>
          Make changes to your profile here. Tap save when you&apos;re done.
        </SheetDescription>
      </SheetHeader>
      <View className="gap-4 px-6">
        <View className="gap-2">
          <Label nativeID="sheet-name">Name</Label>
          <Input
            accessibilityLabelledBy="sheet-name"
            defaultValue="Pedro Duarte"
          />
        </View>
        <View className="gap-2">
          <Label nativeID="sheet-username">Username</Label>
          <Input
            accessibilityLabelledBy="sheet-username"
            defaultValue="@peduarte"
            autoCapitalize="none"
          />
        </View>
      </View>
      <SheetFooter>
        <Button>
          <Text>Save changes</Text>
        </Button>
        <SheetClose render={<Button variant="secondary" />}>
          <Text>Close</Text>
        </SheetClose>
      </SheetFooter>
    </SheetContent>
  )
}

export function SheetOpenByDefaultExample() {
  return (
    <View className="items-start">
      <Sheet defaultOpen>
        <SheetTrigger render={<Button variant="outline" />}>
          <Text>Open</Text>
        </SheetTrigger>
        <ProfileContent />
      </Sheet>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SheetOpenByDefaultExample: View {
  var body: some View {
    ProfileSheet(title: "Open", side: .trailing, open: true)
  }
}

struct ProfileSheet: View {
  let title: LocalizedStringKey
  let side: Edge

  @State private var open: Bool

  init(title: LocalizedStringKey, side: Edge, open: Bool = false) {
    self.title = title
    self.side = side
    _open = State(initialValue: open)
  }

  var body: some View {
    Button(title) { open = true }
      .buttonStyle(.variant(.outline))
      .sheet(isPresented: $open, side: side) {
        ProfileContent()
      }
  }
}

struct ProfileContent: View {
  @State private var name = "Pedro Duarte"
  @State private var username = "@peduarte"

  var body: some View {
    SheetContent {
      SheetHeader {
        SheetTitle("Edit profile")
        SheetDescription("Make changes to your profile here. Tap save when you're done.")
      }
      VStack(alignment: .leading, spacing: 16) {
        VStack(alignment: .leading, spacing: 8) {
          FieldLabel("Name")
            .accessibilityHidden(true)
          TextField("Name", text: $name)
            .textFieldStyle(.input)
        }
        VStack(alignment: .leading, spacing: 8) {
          FieldLabel("Username")
            .accessibilityHidden(true)
          TextField("Username", text: $username)
            .textFieldStyle(.input)
            .textInputAutocapitalization(.never)
        }
      }
      .padding(.horizontal, 24)
      SheetFooter {
        Button {} label: {
          Text("Save changes").frame(maxWidth: .infinity)
        }
        .buttonStyle(.variant())
        SheetClose {
          Text("Close").frame(maxWidth: .infinity)
        }
        .buttonStyle(.variant(.secondary))
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Input
import com.esuiss.ui.components.Label
import com.esuiss.ui.components.Sheet
import com.esuiss.ui.components.SheetDescription
import com.esuiss.ui.components.SheetFooter
import com.esuiss.ui.components.SheetHeader
import com.esuiss.ui.components.SheetSide
import com.esuiss.ui.components.SheetTitle

@Composable
fun SheetOpenByDefaultExample() {
    SheetDefault(defaultOpen = true)
}

@Composable
fun ProfileSheet(open: Boolean, onOpenChange: (Boolean) -> Unit, side: SheetSide = SheetSide.Right) {
    var name by rememberSaveable { mutableStateOf("Pedro Duarte") }
    var username by rememberSaveable { mutableStateOf("@peduarte") }
    Sheet(open = open, onOpenChange = onOpenChange, side = side) {
        SheetHeader {
            SheetTitle("Edit profile")
            SheetDescription("Make changes to your profile here. Tap save when you're done.")
        }
        Column(Modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
            Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                Label("Name")
                Input(name, { name = it }, Modifier.semantics { contentDescription = "Name" })
            }
            Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                Label("Username")
                Input(
                    username,
                    { username = it },
                    Modifier.semantics { contentDescription = "Username" },
                    keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None, autoCorrectEnabled = false),
                )
            }
        }
        SheetFooter {
            Button("Save changes", onClick = {}, modifier = Modifier.fillMaxWidth())
            Button("Close", onClick = { onOpenChange(false) }, modifier = Modifier.fillMaxWidth(), variant = ButtonVariant.Secondary)
        }
    }
}

@Composable
fun SheetDefault(defaultOpen: Boolean) {
    var open by rememberSaveable { mutableStateOf(defaultOpen) }
    Column {
        Button("Open", onClick = { open = true }, variant = ButtonVariant.Outline)
        ProfileSheet(open, { open = it })
    }
}
```

`Sheet` is built on the drawer primitive. `SheetContent` renders in a portal, above a dimmed overlay, as a rounded floating panel inset from the screen edges and the safe area. It enters and exits with a Reanimated fade-and-slide from its `side` and can be dismissed by swiping toward that edge.

Use `right` or `left` for navigation and detail panels that span the screen height, and `top` or `bottom` for short panels that span the width. For snap points and a drag handle, use Drawer instead.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/sheet
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

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

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add sheet --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add sheet --platform kotlin
```

The CLI also adds what it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-safe-area-context react-native-screens react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/drawer.tsx`, `components/primitives/use-render.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/sheet.tsx`:

```tsx title="uniwind/components/ui/sheet.tsx"
import { cn } from "cn"
import { XIcon } from "phosphor-react-native"
import * as React from "react"
import { Platform, View, type ViewProps, type ViewStyle } from "react-native"
import {
  FadeIn,
  FadeInDown,
  FadeInLeft,
  FadeInRight,
  FadeInUp,
  FadeOut,
  FadeOutDown,
  FadeOutLeft,
  FadeOutRight,
  FadeOutUp,
  ReduceMotion,
} from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as DrawerPrimitive from "@/components/primitives/drawer"
import { resolveClassName } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

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

const SWIPE_DIRECTION: Record<SheetSide, DrawerPrimitive.SwipeDirection> = {
  top: "up",
  right: "right",
  bottom: "down",
  left: "left",
}

const ENTERING = {
  top: FadeInUp,
  right: FadeInRight,
  bottom: FadeInDown,
  left: FadeInLeft,
} as const

const EXITING = {
  top: FadeOutUp,
  right: FadeOutRight,
  bottom: FadeOutDown,
  left: FadeOutLeft,
} as const

const POSITION: Record<SheetSide, string> = {
  top: "top-2 left-2 right-2",
  right: "top-2 bottom-2 right-2 w-3/4 sm:max-w-sm",
  bottom: "bottom-2 left-2 right-2",
  left: "top-2 bottom-2 left-2 w-3/4 sm:max-w-sm",
}

const DURATION = 400

const SheetSideContext = React.createContext<
  ((side: SheetSide) => void) | null
>(null)

const SheetCloseContext = React.createContext({
  hasCloseButton: false,
  hasCloseLabel: false,
})

const FullWindowOverlay =
  Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment

function Sheet({
  children,
  ...props
}: Omit<
  DrawerPrimitive.RootProps,
  | "swipeDirection"
  | "snapPoints"
  | "snapPoint"
  | "defaultSnapPoint"
  | "onSnapPointChange"
  | "snapToSequentialPoints"
>) {
  const [side, setSide] = React.useState<SheetSide>("right")
  return (
    <SheetSideContext.Provider value={setSide}>
      <DrawerPrimitive.Root swipeDirection={SWIPE_DIRECTION[side]} {...props}>
        {children}
      </DrawerPrimitive.Root>
    </SheetSideContext.Provider>
  )
}

const SheetTrigger = DrawerPrimitive.Trigger

const SheetClose = DrawerPrimitive.Close

const SheetPortal = DrawerPrimitive.Portal

function SheetOverlay({
  className,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Backdrop>) {
  return (
    <AnimatedView
      entering={FadeIn.duration(DURATION).reduceMotion(ReduceMotion.System)}
      exiting={FadeOut.duration(DURATION).reduceMotion(ReduceMotion.System)}
      className="absolute top-0 right-0 bottom-0 left-0 z-50"
      style={{ pointerEvents: "box-none" }}
    >
      <DrawerPrimitive.Backdrop
        className={(state) =>
          cn(
            "absolute top-0 right-0 bottom-0 left-0 bg-black/50",
            resolveClassName(className, state)
          )
        }
        {...props}
      />
    </AnimatedView>
  )
}

function SheetContent({
  className,
  children,
  portalHost,
  side = "right",
  showCloseButton = true,
  closeLabel,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Popup> & {
  portalHost?: string
  side?: SheetSide
  showCloseButton?: boolean
  closeLabel?: string
}) {
  const setSide = React.useContext(SheetSideContext)
  const insets = useSafeAreaInsets()

  React.useLayoutEffect(() => {
    setSide?.(side)
  }, [setSide, side])

  const frameStyle = React.useMemo<ViewStyle>(
    () => ({
      pointerEvents: "box-none",
      marginTop: side === "bottom" ? 0 : insets.top,
      marginBottom: side === "top" ? 0 : insets.bottom,
      marginLeft: side === "right" ? 0 : insets.left,
      marginRight: side === "left" ? 0 : insets.right,
    }),
    [side, insets]
  )

  const closeContext = React.useMemo(
    () => ({
      hasCloseButton: showCloseButton,
      hasCloseLabel: showCloseButton && closeLabel !== undefined,
    }),
    [showCloseButton, closeLabel]
  )

  return (
    <SheetPortal hostName={portalHost}>
      <FullWindowOverlay>
        <SheetOverlay />
        <DrawerPrimitive.Viewport className="absolute top-0 right-0 bottom-0 left-0 z-50">
          <AnimatedView
            entering={ENTERING[side]
              .duration(DURATION)
              .reduceMotion(ReduceMotion.System)}
            exiting={EXITING[side]
              .duration(DURATION)
              .reduceMotion(ReduceMotion.System)}
            className={cn("absolute z-50", POSITION[side])}
            style={frameStyle}
          >
            <DrawerPrimitive.Popup
              className={(state) =>
                cn(
                  "flex flex-col rounded-3xl bg-popover shadow-xl",
                  (side === "left" || side === "right") && "flex-1",
                  resolveClassName(className, state)
                )
              }
              {...props}
            >
              <SheetCloseContext.Provider value={closeContext}>
                {children}
              </SheetCloseContext.Provider>
              {showCloseButton ? (
                <DrawerPrimitive.Close
                  render={
                    <Button
                      variant={closeLabel ? "plain" : "ghost"}
                      size={closeLabel ? "sm" : "icon-sm"}
                      className={cn(
                        "absolute top-[19px] right-4",
                        !closeLabel && "bg-secondary active:opacity-80"
                      )}
                    />
                  }
                  hitSlop={8}
                  accessibilityLabel={closeLabel ? undefined : "Close"}
                >
                  {closeLabel ? (
                    <Text className="text-base">{closeLabel}</Text>
                  ) : (
                    <Icon as={XIcon} className="size-4 text-foreground" />
                  )}
                </DrawerPrimitive.Close>
              ) : null}
            </DrawerPrimitive.Popup>
          </AnimatedView>
        </DrawerPrimitive.Viewport>
      </FullWindowOverlay>
    </SheetPortal>
  )
}

function SheetHeader({ className, ...props }: ViewProps) {
  const { hasCloseButton, hasCloseLabel } = React.useContext(SheetCloseContext)
  return (
    <View
      className={cn(
        "flex flex-col gap-1.5 p-6",
        hasCloseButton && "pr-14",
        hasCloseLabel && "pr-24",
        className
      )}
      {...props}
    />
  )
}

function SheetFooter({ className, ...props }: ViewProps) {
  return (
    <View
      className={cn("mt-auto flex flex-col gap-2 p-6", className)}
      {...props}
    />
  )
}

function SheetTitle({
  className,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
  return (
    <DrawerPrimitive.Title
      className={(state) =>
        cn(
          "text-base font-semibold text-foreground",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

function SheetDescription({
  className,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
  return (
    <DrawerPrimitive.Description
      className={(state) =>
        cn("text-sm text-muted-foreground", resolveClassName(className, state))
      }
      {...props}
    />
  )
}

export {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
}
```

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-safe-area-context react-native-screens react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/drawer.tsx`, `components/primitives/use-render.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/sheet.tsx`:

```tsx title="nativewind/components/ui/sheet.tsx"
import { cn } from "cn"
import { XIcon } from "phosphor-react-native"
import * as React from "react"
import { Platform, View, type ViewProps, type ViewStyle } from "react-native"
import {
  FadeIn,
  FadeInDown,
  FadeInLeft,
  FadeInRight,
  FadeInUp,
  FadeOut,
  FadeOutDown,
  FadeOutLeft,
  FadeOutRight,
  FadeOutUp,
  ReduceMotion,
} from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as DrawerPrimitive from "@/components/primitives/drawer"
import { resolveClassName } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

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

const SWIPE_DIRECTION: Record<SheetSide, DrawerPrimitive.SwipeDirection> = {
  top: "up",
  right: "right",
  bottom: "down",
  left: "left",
}

const ENTERING = {
  top: FadeInUp,
  right: FadeInRight,
  bottom: FadeInDown,
  left: FadeInLeft,
} as const

const EXITING = {
  top: FadeOutUp,
  right: FadeOutRight,
  bottom: FadeOutDown,
  left: FadeOutLeft,
} as const

const POSITION: Record<SheetSide, string> = {
  top: "top-2 left-2 right-2",
  right: "top-2 bottom-2 right-2 w-3/4 sm:max-w-sm",
  bottom: "bottom-2 left-2 right-2",
  left: "top-2 bottom-2 left-2 w-3/4 sm:max-w-sm",
}

const DURATION = 400

const SheetSideContext = React.createContext<
  ((side: SheetSide) => void) | null
>(null)

const SheetCloseContext = React.createContext({
  hasCloseButton: false,
  hasCloseLabel: false,
})

const FullWindowOverlay =
  Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment

function Sheet({
  children,
  ...props
}: Omit<
  DrawerPrimitive.RootProps,
  | "swipeDirection"
  | "snapPoints"
  | "snapPoint"
  | "defaultSnapPoint"
  | "onSnapPointChange"
  | "snapToSequentialPoints"
>) {
  const [side, setSide] = React.useState<SheetSide>("right")
  return (
    <SheetSideContext.Provider value={setSide}>
      <DrawerPrimitive.Root swipeDirection={SWIPE_DIRECTION[side]} {...props}>
        {children}
      </DrawerPrimitive.Root>
    </SheetSideContext.Provider>
  )
}

const SheetTrigger = DrawerPrimitive.Trigger

const SheetClose = DrawerPrimitive.Close

const SheetPortal = DrawerPrimitive.Portal

function SheetOverlay({
  className,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Backdrop>) {
  return (
    <AnimatedView
      entering={FadeIn.duration(DURATION).reduceMotion(ReduceMotion.System)}
      exiting={FadeOut.duration(DURATION).reduceMotion(ReduceMotion.System)}
      className="absolute top-0 right-0 bottom-0 left-0 z-50"
      style={{ pointerEvents: "box-none" }}
    >
      <DrawerPrimitive.Backdrop
        className={(state) =>
          cn(
            "absolute top-0 right-0 bottom-0 left-0 bg-black/50",
            resolveClassName(className, state)
          )
        }
        {...props}
      />
    </AnimatedView>
  )
}

function SheetContent({
  className,
  children,
  portalHost,
  side = "right",
  showCloseButton = true,
  closeLabel,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Popup> & {
  portalHost?: string
  side?: SheetSide
  showCloseButton?: boolean
  closeLabel?: string
}) {
  const setSide = React.useContext(SheetSideContext)
  const insets = useSafeAreaInsets()

  React.useLayoutEffect(() => {
    setSide?.(side)
  }, [setSide, side])

  const frameStyle = React.useMemo<ViewStyle>(
    () => ({
      pointerEvents: "box-none",
      marginTop: side === "bottom" ? 0 : insets.top,
      marginBottom: side === "top" ? 0 : insets.bottom,
      marginLeft: side === "right" ? 0 : insets.left,
      marginRight: side === "left" ? 0 : insets.right,
    }),
    [side, insets]
  )

  const closeContext = React.useMemo(
    () => ({
      hasCloseButton: showCloseButton,
      hasCloseLabel: showCloseButton && closeLabel !== undefined,
    }),
    [showCloseButton, closeLabel]
  )

  return (
    <SheetPortal hostName={portalHost}>
      <FullWindowOverlay>
        <SheetOverlay />
        <DrawerPrimitive.Viewport className="absolute top-0 right-0 bottom-0 left-0 z-50">
          <AnimatedView
            entering={ENTERING[side]
              .duration(DURATION)
              .reduceMotion(ReduceMotion.System)}
            exiting={EXITING[side]
              .duration(DURATION)
              .reduceMotion(ReduceMotion.System)}
            className={cn("absolute z-50", POSITION[side])}
            style={frameStyle}
          >
            <DrawerPrimitive.Popup
              className={(state) =>
                cn(
                  "flex flex-col rounded-3xl bg-popover shadow-xl",
                  (side === "left" || side === "right") && "flex-1",
                  resolveClassName(className, state)
                )
              }
              {...props}
            >
              <SheetCloseContext.Provider value={closeContext}>
                {children}
              </SheetCloseContext.Provider>
              {showCloseButton ? (
                <DrawerPrimitive.Close
                  render={
                    <Button
                      variant={closeLabel ? "plain" : "ghost"}
                      size={closeLabel ? "sm" : "icon-sm"}
                      className={cn(
                        "absolute top-[19px] right-4",
                        !closeLabel && "bg-secondary active:opacity-80"
                      )}
                    />
                  }
                  hitSlop={8}
                  accessibilityLabel={closeLabel ? undefined : "Close"}
                >
                  {closeLabel ? (
                    <Text className="text-base">{closeLabel}</Text>
                  ) : (
                    <Icon as={XIcon} className="size-4 text-foreground" />
                  )}
                </DrawerPrimitive.Close>
              ) : null}
            </DrawerPrimitive.Popup>
          </AnimatedView>
        </DrawerPrimitive.Viewport>
      </FullWindowOverlay>
    </SheetPortal>
  )
}

function SheetHeader({ className, ...props }: ViewProps) {
  const { hasCloseButton, hasCloseLabel } = React.useContext(SheetCloseContext)
  return (
    <View
      className={cn(
        "flex flex-col gap-1.5 p-6",
        hasCloseButton && "pr-14",
        hasCloseLabel && "pr-24",
        className
      )}
      {...props}
    />
  )
}

function SheetFooter({ className, ...props }: ViewProps) {
  return (
    <View
      className={cn("mt-auto flex flex-col gap-2 p-6", className)}
      {...props}
    />
  )
}

function SheetTitle({
  className,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
  return (
    <DrawerPrimitive.Title
      className={(state) =>
        cn(
          "text-base font-semibold text-foreground",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

function SheetDescription({
  className,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
  return (
    <DrawerPrimitive.Description
      className={(state) =>
        cn("text-sm text-muted-foreground", resolveClassName(className, state))
      }
      {...props}
    />
  )
}

export {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
}
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-safe-area-context react-native-screens react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/drawer.tsx`, `components/primitives/use-render.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/sheet.tsx`:

```tsx title="stylesheet/components/ui/sheet.tsx"
import { XIcon } from "phosphor-react-native"
import * as React from "react"
import {
  Platform,
  StyleSheet,
  useWindowDimensions,
  View,
  type ViewProps,
  type ViewStyle,
} from "react-native"
import {
  FadeIn,
  FadeInDown,
  FadeInLeft,
  FadeInRight,
  FadeInUp,
  FadeOut,
  FadeOutDown,
  FadeOutLeft,
  FadeOutRight,
  FadeOutUp,
  ReduceMotion,
} from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as DrawerPrimitive from "@/components/primitives/drawer"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"
import { fontSize, radius, shadow, useTheme } from "@/components/ui/theme"

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

const SWIPE_DIRECTION: Record<SheetSide, DrawerPrimitive.SwipeDirection> = {
  top: "up",
  right: "right",
  bottom: "down",
  left: "left",
}

const ENTERING = {
  top: FadeInUp,
  right: FadeInRight,
  bottom: FadeInDown,
  left: FadeInLeft,
} as const

const EXITING = {
  top: FadeOutUp,
  right: FadeOutRight,
  bottom: FadeOutDown,
  left: FadeOutLeft,
} as const

const POSITION: Record<SheetSide, ViewStyle> = {
  top: { top: 8, left: 8, right: 8 },
  right: { top: 8, bottom: 8, right: 8, width: "75%" },
  bottom: { bottom: 8, left: 8, right: 8 },
  left: { top: 8, bottom: 8, left: 8, width: "75%" },
}

const DURATION = 400

const styles = StyleSheet.create({
  layer: { ...StyleSheet.absoluteFill, zIndex: 50, pointerEvents: "box-none" },
  fill: { ...StyleSheet.absoluteFill },
  frame: { position: "absolute", zIndex: 50 },
  popup: {
    display: "flex",
    flexDirection: "column",
    borderRadius: radius["3xl"],
    ...shadow.xl,
  },
  popupFill: { flex: 1 },
  close: { position: "absolute", top: 19, right: 16 },
  closeIcon: { flexShrink: 0 },
  closeLabel: { ...fontSize.base },
  header: { display: "flex", flexDirection: "column", gap: 6, padding: 24 },
  footer: {
    marginTop: "auto",
    display: "flex",
    flexDirection: "column",
    gap: 8,
    padding: 24,
  },
  title: { ...fontSize.base, fontWeight: "600" },
  description: { ...fontSize.sm },
})

const SheetSideContext = React.createContext<
  ((side: SheetSide) => void) | null
>(null)

const SheetCloseContext = React.createContext({
  hasCloseButton: false,
  hasCloseLabel: false,
})

const FullWindowOverlay =
  Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment

function Sheet({
  children,
  ...props
}: Omit<
  DrawerPrimitive.RootProps,
  | "swipeDirection"
  | "snapPoints"
  | "snapPoint"
  | "defaultSnapPoint"
  | "onSnapPointChange"
  | "snapToSequentialPoints"
>) {
  const [side, setSide] = React.useState<SheetSide>("right")
  return (
    <SheetSideContext.Provider value={setSide}>
      <DrawerPrimitive.Root swipeDirection={SWIPE_DIRECTION[side]} {...props}>
        {children}
      </DrawerPrimitive.Root>
    </SheetSideContext.Provider>
  )
}

const SheetTrigger = DrawerPrimitive.Trigger

const SheetClose = DrawerPrimitive.Close

const SheetPortal = DrawerPrimitive.Portal

function SheetOverlay({
  style,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Backdrop>) {
  return (
    <AnimatedView
      entering={FadeIn.duration(DURATION).reduceMotion(ReduceMotion.System)}
      exiting={FadeOut.duration(DURATION).reduceMotion(ReduceMotion.System)}
      style={styles.layer}
    >
      <DrawerPrimitive.Backdrop
        style={(state) => [
          styles.fill,
          { backgroundColor: "rgba(0, 0, 0, 0.5)" },
          typeof style === "function" ? style(state) : style,
        ]}
        {...props}
      />
    </AnimatedView>
  )
}

function SheetContent({
  style,
  children,
  portalHost,
  side = "right",
  showCloseButton = true,
  closeLabel,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Popup> & {
  portalHost?: string
  side?: SheetSide
  showCloseButton?: boolean
  closeLabel?: string
}) {
  const { colors } = useTheme()
  const setSide = React.useContext(SheetSideContext)
  const insets = useSafeAreaInsets()
  const { width } = useWindowDimensions()
  const horizontal = side === "left" || side === "right"

  React.useLayoutEffect(() => {
    setSide?.(side)
  }, [setSide, side])

  const frameStyle = React.useMemo<ViewStyle>(
    () => ({
      ...POSITION[side],
      maxWidth: horizontal && width >= 640 ? 384 : undefined,
      pointerEvents: "box-none",
      marginTop: side === "bottom" ? 0 : insets.top,
      marginBottom: side === "top" ? 0 : insets.bottom,
      marginLeft: side === "right" ? 0 : insets.left,
      marginRight: side === "left" ? 0 : insets.right,
    }),
    [side, horizontal, width, insets]
  )

  const closeContext = React.useMemo(
    () => ({
      hasCloseButton: showCloseButton,
      hasCloseLabel: showCloseButton && closeLabel !== undefined,
    }),
    [showCloseButton, closeLabel]
  )

  return (
    <SheetPortal hostName={portalHost}>
      <FullWindowOverlay>
        <SheetOverlay />
        <DrawerPrimitive.Viewport style={styles.layer}>
          <AnimatedView
            entering={ENTERING[side]
              .duration(DURATION)
              .reduceMotion(ReduceMotion.System)}
            exiting={EXITING[side]
              .duration(DURATION)
              .reduceMotion(ReduceMotion.System)}
            style={[styles.frame, frameStyle]}
          >
            <DrawerPrimitive.Popup
              style={(state) => [
                styles.popup,
                { backgroundColor: colors.popover },
                horizontal && styles.popupFill,
                typeof style === "function" ? style(state) : style,
              ]}
              {...props}
            >
              <SheetCloseContext.Provider value={closeContext}>
                {children}
              </SheetCloseContext.Provider>
              {showCloseButton ? (
                <DrawerPrimitive.Close
                  render={
                    <Button
                      variant={closeLabel ? "plain" : "ghost"}
                      size={closeLabel ? "sm" : "icon-sm"}
                      style={({ pressed }) => [
                        styles.close,
                        !closeLabel && {
                          backgroundColor: pressed
                            ? colors.border
                            : colors.secondary,
                        },
                      ]}
                    />
                  }
                  hitSlop={8}
                  accessibilityLabel={closeLabel ? undefined : "Close"}
                >
                  {closeLabel ? (
                    <Text style={styles.closeLabel}>{closeLabel}</Text>
                  ) : (
                    <Icon
                      as={XIcon}
                      size={16}
                      color={colors.foreground}
                      style={styles.closeIcon}
                    />
                  )}
                </DrawerPrimitive.Close>
              ) : null}
            </DrawerPrimitive.Popup>
          </AnimatedView>
        </DrawerPrimitive.Viewport>
      </FullWindowOverlay>
    </SheetPortal>
  )
}

function SheetHeader({ style, ...props }: ViewProps) {
  const { hasCloseButton, hasCloseLabel } = React.useContext(SheetCloseContext)
  return (
    <View
      style={[
        styles.header,
        hasCloseButton && { paddingRight: hasCloseLabel ? 96 : 56 },
        style,
      ]}
      {...props}
    />
  )
}

function SheetFooter({ style, ...props }: ViewProps) {
  return <View style={[styles.footer, style]} {...props} />
}

function SheetTitle({
  style,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
  const { colors } = useTheme()
  return (
    <DrawerPrimitive.Title
      style={(state) => [
        styles.title,
        { color: colors.foreground },
        typeof style === "function" ? style(state) : style,
      ]}
      {...props}
    />
  )
}

function SheetDescription({
  style,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
  const { colors } = useTheme()
  return (
    <DrawerPrimitive.Description
      style={(state) => [
        styles.description,
        { color: colors.mutedForeground },
        typeof style === "function" ? style(state) : style,
      ]}
      {...props}
    />
  )
}

export {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
}
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

```swift title="swift/Components/Sheet.swift"
import SwiftUI

extension View {
  public func sheet<Content: View>(
    isPresented: Binding<Bool>,
    side: Edge,
    @ViewBuilder content: @escaping () -> Content
  ) -> some View {
    modifier(
      CoverPresenter(
        isPresented: isPresented,
        enter: .easeOut(duration: 0.4),
        exit: .easeIn(duration: 0.3)
      ) { visible in
        SideSheetStage(side: side, visible: visible, content: content)
      }
    )
  }
}

extension EnvironmentValues {
  @Entry var sheetSide: Edge = .trailing
}

struct SideSheetStage<Content: View>: View {
  let side: Edge
  let visible: Bool
  @ViewBuilder let content: () -> Content

  @State private var drag: CGFloat = 0
  @Environment(\.overlayDismiss) private var dismiss
  @Environment(\.accessibilityReduceMotion) private var reduceMotion
  @Environment(\.layoutDirection) private var layoutDirection

  var body: some View {
    GeometryReader { proxy in
      let horizontal = side == .leading || side == .trailing
      let width = min(proxy.size.width * 0.75, proxy.size.width >= 640 ? 384 : .infinity)
      ZStack(alignment: alignment) {
        ScrimLayer(visible: visible)
        content()
          .environment(\.sheetSide, side)
          .frame(width: horizontal ? width : nil)
          .frame(maxHeight: horizontal ? .infinity : nil)
          .padding(8)
          .offset(offset(visible: visible))
          .opacity(visible ? 1 : 0)
          .gesture(swipe)
      }
      .frame(width: proxy.size.width, height: proxy.size.height)
    }
    .accessibilityAction(.escape) { dismiss?() }
  }

  private var alignment: Alignment {
    switch side {
    case .top: .top
    case .bottom: .bottom
    case .leading: .leading
    case .trailing: .trailing
    }
  }

  private var direction: CGSize {
    let flip: CGFloat = layoutDirection == .rightToLeft ? -1 : 1
    switch side {
    case .top: return CGSize(width: 0, height: -1)
    case .bottom: return CGSize(width: 0, height: 1)
    case .leading: return CGSize(width: -flip, height: 0)
    case .trailing: return CGSize(width: flip, height: 0)
    }
  }

  private func offset(visible: Bool) -> CGSize {
    let distance = (visible || reduceMotion ? 0 : 25) + drag
    return CGSize(width: direction.width * distance, height: direction.height * distance)
  }

  private var swipe: some Gesture {
    DragGesture(minimumDistance: 10)
      .onChanged { value in
        let moved = value.translation.width * direction.width + value.translation.height * direction.height
        drag = max(0, moved)
      }
      .onEnded { value in
        let predicted = value.predictedEndTranslation.width * direction.width
          + value.predictedEndTranslation.height * direction.height
        if drag > 80 || predicted > 240 {
          dismiss?()
          withAnimation(.easeIn(duration: 0.3)) { drag += 60 }
        } else {
          withAnimation(.spring(duration: 0.3)) { drag = 0 }
        }
      }
  }
}

public struct SheetContent<Content: View>: View {
  let showCloseButton: Bool
  let closeLabel: LocalizedStringKey?
  let content: Content

  @Environment(\.sheetSide) private var side

  public init(
    showCloseButton: Bool = true,
    closeLabel: LocalizedStringKey? = nil,
    @ViewBuilder content: () -> Content
  ) {
    self.showCloseButton = showCloseButton
    self.closeLabel = closeLabel
    self.content = content()
  }

  public var body: some View {
    let horizontal = side == .leading || side == .trailing
    let shape = RoundedRectangle(cornerRadius: Radius.xl3, style: .continuous)
    VStack(alignment: .leading, spacing: 0) {
      content
    }
    .frame(maxWidth: .infinity, maxHeight: horizontal ? .infinity : nil, alignment: .topLeading)
    .fixedSize(horizontal: false, vertical: !horizontal)
    .environment(\.overlayCloseInset, showCloseButton ? (closeLabel == nil ? 56 : 96) : 0)
    .clipShape(shape)
    .background {
      shape
        .fill(EsuissColor.popover)
        .shadow(.lg)
    }
    .overlay(alignment: .topTrailing) {
      if showCloseButton {
        OverlayCloseButton(label: closeLabel)
          .padding(.top, closeLabel == nil ? 19 : 17)
          .padding(.trailing, 16)
      }
    }
    .accessibilityElement(children: .contain)
    .accessibilityAddTraits(.isModal)
  }
}

public struct SheetHeader<Content: View>: View {
  let content: Content

  @Environment(\.overlayCloseInset) private var closeInset

  public init(@ViewBuilder content: () -> Content) {
    self.content = content()
  }

  public var body: some View {
    VStack(alignment: .leading, spacing: 6) {
      content
    }
    .frame(maxWidth: .infinity, alignment: .leading)
    .padding(24)
    .padding(.trailing, max(closeInset - 24, 0))
  }
}

public struct SheetFooter<Content: View>: View {
  let content: Content

  public init(@ViewBuilder content: () -> Content) {
    self.content = content()
  }

  public var body: some View {
    VStack(spacing: 0) {
      Spacer(minLength: 0)
      VStack(spacing: 8) {
        content
      }
      .frame(maxWidth: .infinity)
      .padding(24)
    }
  }
}

public typealias SheetTitle = DialogTitle
public typealias SheetDescription = DialogDescription
public typealias SheetClose<Label: View> = DialogClose<Label>
```

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Dialog.swift`: see [Dialog](https://ui.suiss.app/docs/components/dialog.md)

- `swift/Components/Overlay.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/Overlay.swift)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/components/Sheet.kt"
package com.esuiss.ui.components

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideIn
import androidx.compose.animation.slideOut
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.AbsoluteAlignment
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.paneTitle
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Shadows
import com.esuiss.ui.theme.Typography
import com.esuiss.ui.theme.shadow
import kotlin.math.roundToInt
import kotlinx.coroutines.launch

enum class SheetSide { Top, Right, Bottom, Left }

@Immutable
private data class SheetInfo(
    val hasCloseButton: Boolean,
    val hasCloseLabel: Boolean,
    val fillsHeight: Boolean,
)

private val LocalSheetInfo = staticCompositionLocalOf { SheetInfo(false, false, false) }

private const val SheetDuration = 400

@Composable
fun Sheet(
    open: Boolean,
    onOpenChange: (Boolean) -> Unit,
    modifier: Modifier = Modifier,
    side: SheetSide = SheetSide.Right,
    showCloseButton: Boolean = true,
    closeLabel: String? = null,
    content: @Composable ColumnScope.() -> Unit,
) {
    val transition = remember { MutableTransitionState(false) }
    transition.targetState = open
    val paneTitle = remember { PaneTitleState() }
    val drag = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()
    val close = { if (open) onOpenChange(false) }
    LaunchedEffect(open) { if (open) drag.snapTo(0f) }

    val horizontal = side == SheetSide.Left || side == SheetSide.Right
    val sign = if (side == SheetSide.Right || side == SheetSide.Bottom) 1f else -1f
    val alignment = when (side) {
        SheetSide.Top -> AbsoluteAlignment.TopLeft
        SheetSide.Right -> AbsoluteAlignment.CenterRight
        SheetSide.Bottom -> AbsoluteAlignment.BottomLeft
        SheetSide.Left -> AbsoluteAlignment.CenterLeft
    }
    val insetSides = when (side) {
        SheetSide.Top -> WindowInsetsSides.Top + WindowInsetsSides.Horizontal
        SheetSide.Bottom -> WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal
        SheetSide.Left -> WindowInsetsSides.Vertical + WindowInsetsSides.Left
        SheetSide.Right -> WindowInsetsSides.Vertical + WindowInsetsSides.Right
    }
    val colors = EsuissTheme.colors
    val density = LocalDensity.current
    val slide = with(density) { 25.dp.roundToPx() }
    fun edgeOffset(): IntOffset = when (side) {
        SheetSide.Top -> IntOffset(0, -slide)
        SheetSide.Bottom -> IntOffset(0, slide)
        SheetSide.Left -> IntOffset(-slide, 0)
        SheetSide.Right -> IntOffset(slide, 0)
    }

    ModalWindow(
        visible = transition.currentState || transition.targetState,
        onDismissRequest = close,
        dismissOnBackPress = true,
        paneTitle = paneTitle.value,
    ) {
        BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
            val wide = maxWidth >= 640.dp
            Scrim(transition, SheetDuration, SheetDuration, onClick = close)
            AnimatedVisibility(
                visibleState = transition,
                modifier = Modifier
                    .align(alignment)
                    .windowInsetsPadding(WindowInsets.safeDrawing.only(insetSides))
                    .padding(8.dp),
                enter = fadeIn(tween(SheetDuration)) + slideIn(tween(SheetDuration)) { edgeOffset() },
                exit = fadeOut(tween(SheetDuration)) + slideOut(tween(SheetDuration)) { edgeOffset() },
            ) {
                val extent = remember { mutableFloatStateOf(1f) }
                val shape = RoundedCornerShape(Radius.xl3)
                val sizing = if (horizontal) {
                    val width = if (wide) minOf(this@BoxWithConstraints.maxWidth * 0.75f, 384.dp) else this@BoxWithConstraints.maxWidth * 0.75f
                    Modifier.width(width).fillMaxHeight()
                } else {
                    Modifier.fillMaxWidth()
                }
                Box(
                    modifier = modifier
                        .then(sizing)
                        .onSizeChanged { extent.floatValue = (if (horizontal) it.width else it.height).toFloat() }
                        .absoluteOffset {
                            val value = (drag.value * sign).roundToInt()
                            if (horizontal) IntOffset(value, 0) else IntOffset(0, value)
                        }
                        .draggable(
                            orientation = if (horizontal) Orientation.Horizontal else Orientation.Vertical,
                            state = rememberDraggableState { delta ->
                                scope.launch { drag.snapTo((drag.value + delta * sign).coerceAtLeast(0f)) }
                            },
                            onDragStopped = { velocity ->
                                if (drag.value > extent.floatValue * 0.3f || velocity * sign > 1000f) {
                                    close()
                                } else {
                                    drag.animateTo(0f, tween(200))
                                }
                            },
                        )
                        .shadow(Shadows.lg, shape)
                        .clip(shape)
                        .background(colors.popover, shape)
                        .pointerInput(Unit) {}
                        .semantics { paneTitle.value?.let { this.paneTitle = it } },
                ) {
                    CompositionLocalProvider(
                        LocalPaneTitle provides paneTitle,
                        LocalSheetInfo provides SheetInfo(showCloseButton, showCloseButton && closeLabel != null, horizontal),
                        LocalContentStyle provides Typography.base.copy(color = colors.foreground),
                        LocalContentColor provides colors.foreground,
                    ) {
                        Column(
                            modifier = if (horizontal) Modifier.fillMaxSize() else Modifier.fillMaxWidth(),
                            content = content,
                        )
                    }
                    if (showCloseButton) {
                        OverlayCloseButton(
                            onClick = close,
                            label = closeLabel,
                            modifier = Modifier
                                .align(Alignment.TopEnd)
                                .padding(
                                    top = if (closeLabel != null) 19.dp else 13.dp,
                                    end = if (closeLabel != null) 16.dp else 10.dp,
                                ),
                        )
                    }
                }
            }
        }
    }
}

@Composable
fun SheetHeader(
    modifier: Modifier = Modifier,
    content: @Composable ColumnScope.() -> Unit,
) {
    val info = LocalSheetInfo.current
    val end = when {
        info.hasCloseLabel -> 96.dp
        info.hasCloseButton -> 56.dp
        else -> 24.dp
    }
    Column(
        modifier = modifier.fillMaxWidth().padding(start = 24.dp, top = 24.dp, bottom = 24.dp, end = end),
        verticalArrangement = Arrangement.spacedBy(6.dp),
        content = content,
    )
}

@Composable
fun ColumnScope.SheetFooter(
    modifier: Modifier = Modifier,
    content: @Composable ColumnScope.() -> Unit,
) {
    if (LocalSheetInfo.current.fillsHeight) {
        Spacer(Modifier.weight(1f))
    }
    Column(
        modifier = modifier.fillMaxWidth().padding(24.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        content = content,
    )
}

@Composable
fun SheetTitle(text: String, modifier: Modifier = Modifier) {
    OverlayTitle(text, modifier, titleStyle(EsuissTheme.colors))
}

@Composable
fun SheetDescription(text: String, modifier: Modifier = Modifier) {
    BasicText(text, modifier = modifier, style = descriptionStyle(EsuissTheme.colors))
}
```

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/Overlay.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Overlay.kt)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { Sheet, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription } from "@/components/ui/sheet"
```

```tsx
<View className="items-start">
  <Sheet defaultOpen>
    <SheetTrigger render={<Button variant="outline" />}>
      <Text>Open</Text>
    </SheetTrigger>
    <ProfileContent />
  </Sheet>
</View>
```

Wrap a `SheetTrigger` and a `SheetContent` in `Sheet`. Inside the content, compose `SheetHeader` with `SheetTitle` and `SheetDescription`, your body, and a `SheetFooter`, which is pushed to the bottom. Use `SheetTrigger` and `SheetClose` with `render` to turn a `Button` into a trigger or close control.

`Sheet` accepts `open`, `defaultOpen` and `onOpenChange` for controlled or uncontrolled use, plus `modal`, `disablePointerDismissal`, `onOpenChangeComplete` and `actionsRef`. The swipe direction follows the `side` of its content.

A close button is shown in the top corner by default. Set `closeLabel` to show a text button instead of the X icon, or `showCloseButton={false}` to remove it. `SheetHeader` adds end padding so the title does not run under the button.

## Examples

### Default

![Preview](https://ui.suiss.app/previews/components-sheet--default.light.webp)

```tsx
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { Text } from "@/components/ui/text"

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

function ProfileContent({ side }: { side?: Side }) {
  return (
    <SheetContent side={side}>
      <SheetHeader>
        <SheetTitle>Edit profile</SheetTitle>
        <SheetDescription>
          Make changes to your profile here. Tap save when you&apos;re done.
        </SheetDescription>
      </SheetHeader>
      <View className="gap-4 px-6">
        <View className="gap-2">
          <Label nativeID="sheet-name">Name</Label>
          <Input
            accessibilityLabelledBy="sheet-name"
            defaultValue="Pedro Duarte"
          />
        </View>
        <View className="gap-2">
          <Label nativeID="sheet-username">Username</Label>
          <Input
            accessibilityLabelledBy="sheet-username"
            defaultValue="@peduarte"
            autoCapitalize="none"
          />
        </View>
      </View>
      <SheetFooter>
        <Button>
          <Text>Save changes</Text>
        </Button>
        <SheetClose render={<Button variant="secondary" />}>
          <Text>Close</Text>
        </SheetClose>
      </SheetFooter>
    </SheetContent>
  )
}

export function SheetExample() {
  return (
    <View className="items-start">
      <Sheet>
        <SheetTrigger render={<Button variant="outline" />}>
          <Text>Open</Text>
        </SheetTrigger>
        <ProfileContent />
      </Sheet>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SheetExample: View {
  var body: some View {
    ProfileSheet(title: "Open", side: .trailing)
  }
}

struct ProfileSheet: View {
  let title: LocalizedStringKey
  let side: Edge

  @State private var open: Bool

  init(title: LocalizedStringKey, side: Edge, open: Bool = false) {
    self.title = title
    self.side = side
    _open = State(initialValue: open)
  }

  var body: some View {
    Button(title) { open = true }
      .buttonStyle(.variant(.outline))
      .sheet(isPresented: $open, side: side) {
        ProfileContent()
      }
  }
}

struct ProfileContent: View {
  @State private var name = "Pedro Duarte"
  @State private var username = "@peduarte"

  var body: some View {
    SheetContent {
      SheetHeader {
        SheetTitle("Edit profile")
        SheetDescription("Make changes to your profile here. Tap save when you're done.")
      }
      VStack(alignment: .leading, spacing: 16) {
        VStack(alignment: .leading, spacing: 8) {
          FieldLabel("Name")
            .accessibilityHidden(true)
          TextField("Name", text: $name)
            .textFieldStyle(.input)
        }
        VStack(alignment: .leading, spacing: 8) {
          FieldLabel("Username")
            .accessibilityHidden(true)
          TextField("Username", text: $username)
            .textFieldStyle(.input)
            .textInputAutocapitalization(.never)
        }
      }
      .padding(.horizontal, 24)
      SheetFooter {
        Button {} label: {
          Text("Save changes").frame(maxWidth: .infinity)
        }
        .buttonStyle(.variant())
        SheetClose {
          Text("Close").frame(maxWidth: .infinity)
        }
        .buttonStyle(.variant(.secondary))
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Input
import com.esuiss.ui.components.Label
import com.esuiss.ui.components.Sheet
import com.esuiss.ui.components.SheetDescription
import com.esuiss.ui.components.SheetFooter
import com.esuiss.ui.components.SheetHeader
import com.esuiss.ui.components.SheetSide
import com.esuiss.ui.components.SheetTitle

@Composable
fun SheetExample() {
    SheetDefault(defaultOpen = false)
}

@Composable
fun ProfileSheet(open: Boolean, onOpenChange: (Boolean) -> Unit, side: SheetSide = SheetSide.Right) {
    var name by rememberSaveable { mutableStateOf("Pedro Duarte") }
    var username by rememberSaveable { mutableStateOf("@peduarte") }
    Sheet(open = open, onOpenChange = onOpenChange, side = side) {
        SheetHeader {
            SheetTitle("Edit profile")
            SheetDescription("Make changes to your profile here. Tap save when you're done.")
        }
        Column(Modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
            Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                Label("Name")
                Input(name, { name = it }, Modifier.semantics { contentDescription = "Name" })
            }
            Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                Label("Username")
                Input(
                    username,
                    { username = it },
                    Modifier.semantics { contentDescription = "Username" },
                    keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None, autoCorrectEnabled = false),
                )
            }
        }
        SheetFooter {
            Button("Save changes", onClick = {}, modifier = Modifier.fillMaxWidth())
            Button("Close", onClick = { onOpenChange(false) }, modifier = Modifier.fillMaxWidth(), variant = ButtonVariant.Secondary)
        }
    }
}

@Composable
fun SheetDefault(defaultOpen: Boolean) {
    var open by rememberSaveable { mutableStateOf(defaultOpen) }
    Column {
        Button("Open", onClick = { open = true }, variant = ButtonVariant.Outline)
        ProfileSheet(open, { open = it })
    }
}
```

### Sides

One sheet for each side: `top`, `right`, `bottom` and `left`.

![Preview](https://ui.suiss.app/previews/components-sheet--sides.light.webp)

```tsx
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { Text } from "@/components/ui/text"

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

function ProfileContent({ side }: { side?: Side }) {
  return (
    <SheetContent side={side}>
      <SheetHeader>
        <SheetTitle>Edit profile</SheetTitle>
        <SheetDescription>
          Make changes to your profile here. Tap save when you&apos;re done.
        </SheetDescription>
      </SheetHeader>
      <View className="gap-4 px-6">
        <View className="gap-2">
          <Label nativeID="sheet-name">Name</Label>
          <Input
            accessibilityLabelledBy="sheet-name"
            defaultValue="Pedro Duarte"
          />
        </View>
        <View className="gap-2">
          <Label nativeID="sheet-username">Username</Label>
          <Input
            accessibilityLabelledBy="sheet-username"
            defaultValue="@peduarte"
            autoCapitalize="none"
          />
        </View>
      </View>
      <SheetFooter>
        <Button>
          <Text>Save changes</Text>
        </Button>
        <SheetClose render={<Button variant="secondary" />}>
          <Text>Close</Text>
        </SheetClose>
      </SheetFooter>
    </SheetContent>
  )
}

export function SheetSidesExample() {
  return (
    <View className="items-start gap-3">
      {(["top", "right", "bottom", "left"] as const).map((side) => (
        <Sheet key={side}>
          <SheetTrigger render={<Button variant="outline" />}>
            <Text className="capitalize">{side}</Text>
          </SheetTrigger>
          <ProfileContent side={side} />
        </Sheet>
      ))}
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SheetSidesExample: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      ProfileSheet(title: "Top", side: .top)
      ProfileSheet(title: "Right", side: .trailing)
      ProfileSheet(title: "Bottom", side: .bottom)
      ProfileSheet(title: "Left", side: .leading)
    }
  }
}

struct ProfileSheet: View {
  let title: LocalizedStringKey
  let side: Edge

  @State private var open: Bool

  init(title: LocalizedStringKey, side: Edge, open: Bool = false) {
    self.title = title
    self.side = side
    _open = State(initialValue: open)
  }

  var body: some View {
    Button(title) { open = true }
      .buttonStyle(.variant(.outline))
      .sheet(isPresented: $open, side: side) {
        ProfileContent()
      }
  }
}

struct ProfileContent: View {
  @State private var name = "Pedro Duarte"
  @State private var username = "@peduarte"

  var body: some View {
    SheetContent {
      SheetHeader {
        SheetTitle("Edit profile")
        SheetDescription("Make changes to your profile here. Tap save when you're done.")
      }
      VStack(alignment: .leading, spacing: 16) {
        VStack(alignment: .leading, spacing: 8) {
          FieldLabel("Name")
            .accessibilityHidden(true)
          TextField("Name", text: $name)
            .textFieldStyle(.input)
        }
        VStack(alignment: .leading, spacing: 8) {
          FieldLabel("Username")
            .accessibilityHidden(true)
          TextField("Username", text: $username)
            .textFieldStyle(.input)
            .textInputAutocapitalization(.never)
        }
      }
      .padding(.horizontal, 24)
      SheetFooter {
        Button {} label: {
          Text("Save changes").frame(maxWidth: .infinity)
        }
        .buttonStyle(.variant())
        SheetClose {
          Text("Close").frame(maxWidth: .infinity)
        }
        .buttonStyle(.variant(.secondary))
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Input
import com.esuiss.ui.components.Label
import com.esuiss.ui.components.Sheet
import com.esuiss.ui.components.SheetDescription
import com.esuiss.ui.components.SheetFooter
import com.esuiss.ui.components.SheetHeader
import com.esuiss.ui.components.SheetSide
import com.esuiss.ui.components.SheetTitle

@Composable
fun ProfileSheet(open: Boolean, onOpenChange: (Boolean) -> Unit, side: SheetSide = SheetSide.Right) {
    var name by rememberSaveable { mutableStateOf("Pedro Duarte") }
    var username by rememberSaveable { mutableStateOf("@peduarte") }
    Sheet(open = open, onOpenChange = onOpenChange, side = side) {
        SheetHeader {
            SheetTitle("Edit profile")
            SheetDescription("Make changes to your profile here. Tap save when you're done.")
        }
        Column(Modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
            Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                Label("Name")
                Input(name, { name = it }, Modifier.semantics { contentDescription = "Name" })
            }
            Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                Label("Username")
                Input(
                    username,
                    { username = it },
                    Modifier.semantics { contentDescription = "Username" },
                    keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None, autoCorrectEnabled = false),
                )
            }
        }
        SheetFooter {
            Button("Save changes", onClick = {}, modifier = Modifier.fillMaxWidth())
            Button("Close", onClick = { onOpenChange(false) }, modifier = Modifier.fillMaxWidth(), variant = ButtonVariant.Secondary)
        }
    }
}

@Composable
fun SheetSides() {
    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        SheetSide.entries.forEach { side ->
            var open by rememberSaveable(side.name) { mutableStateOf(false) }
            Button(side.name, onClick = { open = true }, variant = ButtonVariant.Outline)
            ProfileSheet(open, { open = it }, side)
        }
    }
}
```

### With close label

A bottom sheet whose close button reads "Done" instead of showing an icon.

![Preview](https://ui.suiss.app/previews/components-sheet--with-close-label.light.webp)

```tsx
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { Text } from "@/components/ui/text"

export function SheetWithCloseLabelExample() {
  return (
    <View className="items-start">
      <Sheet>
        <SheetTrigger render={<Button variant="outline" />}>
          <Text>Open with label</Text>
        </SheetTrigger>
        <SheetContent side="bottom" closeLabel="Done">
          <SheetHeader>
            <SheetTitle>Notifications</SheetTitle>
            <SheetDescription>
              Choose what you want to be notified about.
            </SheetDescription>
          </SheetHeader>
        </SheetContent>
      </Sheet>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct CloseLabelSheet: View {
  @State private var open = false

  var body: some View {
    Button("Open with label") { open = true }
      .buttonStyle(.variant(.outline))
      .sheet(isPresented: $open, side: .bottom) {
        SheetContent(closeLabel: "Done") {
          SheetHeader {
            SheetTitle("Notifications")
            SheetDescription("Choose what you want to be notified about.")
          }
        }
      }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Sheet
import com.esuiss.ui.components.SheetDescription
import com.esuiss.ui.components.SheetHeader
import com.esuiss.ui.components.SheetSide
import com.esuiss.ui.components.SheetTitle

@Composable
fun SheetWithCloseLabel() {
    var open by rememberSaveable { mutableStateOf(false) }
    Column {
        Button("Open with label", onClick = { open = true }, variant = ButtonVariant.Outline)
        Sheet(open = open, onOpenChange = { open = it }, side = SheetSide.Bottom, closeLabel = "Done") {
            SheetHeader {
                SheetTitle("Notifications")
                SheetDescription("Choose what you want to be notified about.")
            }
        }
    }
}
```

## API Reference

### Sheet

Root that holds the open state.

Also accepts the props of `DrawerPrimitive.RootProps`.

### SheetContent

The panel, rendered in a portal with an overlay and an optional close button.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `portalHost` | `string` | - | Name of the portal host to render into. |
| `side` | `SheetSide` | `"right"` | Edge the sheet slides in from: `top`, `right`, `bottom` or `left`. Defaults to `right`. |
| `showCloseButton` | `boolean` | `true` | Shows the close button in the top corner. Defaults to `true`. |
| `closeLabel` | `string` | - | Text for the close button. When omitted, an X icon is shown. |

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

### SheetHeader

Column for the title and description.

Also accepts the props of `ViewProps`.

### SheetFooter

Column of actions pushed to the bottom of the panel.

Also accepts the props of `ViewProps`.

### SheetTitle

Heading that labels the sheet.

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

### SheetDescription

Secondary text under the title.

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

## Accessibility

- The panel has the `dialog` role and is labeled by `SheetTitle` when one is present.
- It is modal by default, so VoiceOver keeps focus inside the sheet.
- The VoiceOver escape gesture and the Android back button close the sheet.
- The icon-only close button has the accessibility label "Close".
- Animations respect the system reduce motion setting.

## Notes

- Uses `bg-popover` for the panel and `bg-black/50` for the overlay; colors follow the system appearance.
- On iOS the sheet renders inside a `FullWindowOverlay` from `react-native-screens`, so it appears above native modals.
- Side sheets are three quarters of the screen width, capped at `max-w-sm` on larger screens.

