# Bottom Sheet

> Draggable bottom sheet that snaps between stops, dismisses when pulled down and cycles on handle click.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/bottom-sheet
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/bottom-sheet.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/bottom-sheet-demo.tsx"
"use client"

import * as React from "react"
import { Bell, Lock, Moon, Wifi } from "lucide-react"

import { BottomSheet } from "@/components/bottom-sheet"

const rows = [
  { icon: Wifi, label: "Wi‑Fi", hint: "Home network" },
  { icon: Bell, label: "Notifications", hint: "Mentions only" },
  { icon: Moon, label: "Do not disturb", hint: "Off" },
  { icon: Lock, label: "Privacy", hint: "Reviewed today" },
]

export default function BottomSheetDemo() {
  const [open, setOpen] = React.useState(true)

  return (
    <div className="relative h-[440px] w-72 overflow-hidden rounded-[2rem] border-4 border-foreground/80 bg-card">
      <div className="grid justify-items-center gap-3 p-6 pt-10">
        <div className="size-16 rounded-2xl bg-gradient-to-br from-indigo-500 to-fuchsia-500" />
        <p className="text-sm font-semibold">Settings</p>
        <button type="button" onClick={() => setOpen(true)} className="inline-flex h-9 items-center rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground">
          Open sheet
        </button>
      </div>
      <BottomSheet open={open} onOpenChange={setOpen} title="Quick settings" snapPoints={[0.45, 0.85]}>
        <ul className="grid gap-1">
          {rows.map(({ icon: Icon, label, hint }) => (
            <li key={label} className="flex items-center gap-3 rounded-xl p-2 hover:bg-accent">
              <span className="flex size-9 items-center justify-center rounded-lg bg-muted">
                <Icon className="size-4" />
              </span>
              <span className="grid text-sm">
                <span className="font-medium">{label}</span>
                <span className="text-xs text-muted-foreground">{hint}</span>
              </span>
            </li>
          ))}
        </ul>
      </BottomSheet>
    </div>
  )
}
```

## Source

```tsx title="components/bottom-sheet.tsx"
"use client"

import * as React from "react"
import { X } from "lucide-react"

import { cn } from "@/lib/utils"

export interface BottomSheetProps extends Omit<React.ComponentProps<"div">, "title"> {
  open: boolean
  onOpenChange: (open: boolean) => void
  title?: React.ReactNode
  /** Visible fractions of the container, ascending */
  snapPoints?: number[]
  /** Index into snapPoints the sheet opens at */
  defaultSnap?: number
}

// Fills the nearest positioned ancestor. Drag the handle to resize or dismiss; click it to cycle snap points.
export function BottomSheet({
  open,
  onOpenChange,
  title,
  snapPoints = [0.4, 0.85],
  defaultSnap = 0,
  className,
  children,
  ...props
}: BottomSheetProps) {
  const [snap, setSnap] = React.useState(defaultSnap)
  const [delta, setDelta] = React.useState<number | null>(null)
  const sheet = React.useRef<HTMLDivElement>(null)
  const drag = React.useRef({ start: 0, limit: 0, moved: false })
  const max = snapPoints[snapPoints.length - 1]
  const hidden = (1 - snapPoints[snap] / max) * 100

  return (
    <div className="pointer-events-none absolute inset-0 overflow-hidden">
      <div
        onClick={() => onOpenChange(false)}
        aria-hidden
        className={cn("absolute inset-0 bg-black/40 transition-opacity duration-300", open ? "pointer-events-auto opacity-100" : "opacity-0")}
      />
      <div
        ref={sheet}
        role="dialog"
        aria-label={typeof title === "string" ? title : "Sheet"}
        inert={!open}
        onKeyDown={(event) => {
          if (event.key === "Escape") onOpenChange(false)
        }}
        style={{
          height: `${max * 100}%`,
          transform: open ? `translateY(calc(${hidden}% + ${delta ?? 0}px))` : "translateY(100%)",
        }}
        className={cn(
          "pointer-events-auto absolute inset-x-0 bottom-0 flex flex-col rounded-t-3xl border-t bg-background text-foreground shadow-2xl",
          delta === null && "transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]",
          className
        )}
        {...props}
      >
        <div className="flex items-center gap-2 px-4 pt-2 pb-3">
          <button
            type="button"
            aria-label="Resize sheet"
            onPointerDown={(event) => {
              event.currentTarget.setPointerCapture(event.pointerId)
              const height = sheet.current?.offsetHeight ?? 0
              drag.current = { start: event.clientY, limit: (1 - snapPoints[snap] / max) * height, moved: false }
              setDelta(0)
            }}
            onPointerMove={(event) => {
              if (delta === null) return
              const next = Math.max(event.clientY - drag.current.start, -drag.current.limit)
              if (Math.abs(next) > 4) drag.current.moved = true
              setDelta(next)
            }}
            onPointerUp={() => {
              if (delta === null) return
              const height = sheet.current?.offsetHeight ?? 0
              const visible = (snapPoints[snap] / max) * height - delta
              const stops = [0, ...snapPoints.map((point) => (point / max) * height)]
              const nearest = stops.reduce((best, stop, i) => (Math.abs(stop - visible) < Math.abs(stops[best] - visible) ? i : best), 0)
              setDelta(null)
              if (!drag.current.moved) setSnap((snap + 1) % snapPoints.length)
              else if (nearest === 0) onOpenChange(false)
              else setSnap(nearest - 1)
            }}
            onPointerCancel={() => setDelta(null)}
            style={{ touchAction: "none" }}
            className="group flex h-6 flex-1 cursor-grab items-start justify-center focus-visible:outline-none active:cursor-grabbing"
          >
            <span className="mt-1 h-1.5 w-10 rounded-full bg-muted-foreground/30 transition-colors group-hover:bg-muted-foreground/50 group-focus-visible:bg-ring" />
          </button>
          <button
            type="button"
            aria-label="Close"
            onClick={() => onOpenChange(false)}
            className="flex size-7 items-center justify-center rounded-full bg-muted text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
          >
            <X className="size-4" />
          </button>
        </div>
        {title && <h2 className="px-5 pb-2 text-lg font-semibold">{title}</h2>}
        <div className="flex-1 overflow-y-auto px-5 pb-6">{children}</div>
      </div>
    </div>
  )
}
```
