Bottom Sheet
A mobile-style bottom sheet with drag-to-dismiss, a grab handle, optional snap points, and a sticky footer.
Installation
npx cano-ui add bottom-sheet
Installs vaul automatically if missing.
Source
"use client"
import * as React from "react"
import { Drawer as VaulPrimitive } from "vaul"
import { cn } from "@/lib/utils"
export interface BottomSheetProps {
/** Element that opens the sheet, rendered via Radix `asChild`. */
trigger?: React.ReactNode
title: string
description?: string
/** Hides the drag handle bar at the top. */
withHandle?: boolean
/** Vaul snap points, e.g. [0.4, 1] — omit for content height. */
snapPoints?: (number | string)[]
footer?: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
className?: string
children?: React.ReactNode
}
export function BottomSheet({
trigger,
title,
description,
withHandle = true,
snapPoints,
footer,
open,
onOpenChange,
className,
children,
}: BottomSheetProps) {
// Vaul requires activeSnapPoint state when snapPoints are used.
const [snap, setSnap] = React.useState<number | string | null>(
snapPoints?.[0] ?? null
)
return (
<VaulPrimitive.Root
open={open}
onOpenChange={onOpenChange}
{...(snapPoints
? { snapPoints, activeSnapPoint: snap, setActiveSnapPoint: setSnap }
: {})}
>
{trigger ? (
<VaulPrimitive.Trigger asChild>{trigger}</VaulPrimitive.Trigger>
) : null}
<VaulPrimitive.Portal>
<VaulPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
<VaulPrimitive.Content
data-slot="bottom-sheet"
className={cn(
"fixed inset-x-0 bottom-0 z-50 flex max-h-[92dvh] flex-col rounded-t-xl border-t bg-background",
snapPoints && "h-full",
className
)}
>
{withHandle ? (
<div
aria-hidden="true"
className="mx-auto mt-3 h-1.5 w-10 shrink-0 rounded-full bg-muted"
/>
) : null}
<div className="flex flex-col gap-1.5 p-4 text-center sm:text-left">
<VaulPrimitive.Title className="font-semibold text-foreground">
{title}
</VaulPrimitive.Title>
{description ? (
<VaulPrimitive.Description className="text-sm text-muted-foreground">
{description}
</VaulPrimitive.Description>
) : (
<VaulPrimitive.Description className="sr-only">
{title}
</VaulPrimitive.Description>
)}
</div>
<div className="flex-1 overflow-y-auto px-4 pb-4">{children}</div>
{footer ? (
<div className="flex flex-col gap-2 border-t p-4 pb-[max(theme(spacing.4),env(safe-area-inset-bottom))]">
{footer}
</div>
) : null}
</VaulPrimitive.Content>
</VaulPrimitive.Portal>
</VaulPrimitive.Root>
)
}
This exact file lands in your project at components/ui/bottom-sheet.tsx. It is yours to edit.