Drag between peek, half and full; the list scrolls only once it's open.
Dialogs & sheets@base-ui/react
01Preview
Homepage v3
1440 × 3200
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @base-ui/react03Usage
import {
BottomSheet, BottomSheetTrigger, BottomSheetContent, BottomSheetHandle,
BottomSheetHeader, BottomSheetTitle, BottomSheetDescription, BottomSheetBody, BottomSheetClose,
} from "@/components/ui/bottom-sheet";
<BottomSheet snapPoints={["82px", 0.55, 1]}>
<BottomSheetTrigger render={<Button />}>10 comments</BottomSheetTrigger>
<BottomSheetContent>
<BottomSheetHandle />
<BottomSheetHeader>
<BottomSheetTitle>Comments</BottomSheetTitle>
<BottomSheetDescription>3 unresolved</BottomSheetDescription>
</BottomSheetHeader>
<BottomSheetBody>…</BottomSheetBody>
</BottomSheetContent>
</BottomSheet>04Source
"use client";
import { Drawer } from "@base-ui/react/drawer";
import { createContext, useContext, useRef } from "react";
import { cn } from "@/lib/cn";
import { useControllableState } from "@/lib/use-controllable-state";
export type SnapPoint = number | string;
type Ctx = {
points: SnapPoint[];
snap: SnapPoint | null;
setSnap: (s: SnapPoint) => void;
};
const SheetCtx = createContext<Ctx | null>(null);
const useSheet = () => {
const ctx = useContext(SheetCtx);
if (!ctx) throw new Error("BottomSheet parts must be inside <BottomSheet>");
return ctx;
};
export type BottomSheetProps = Omit<
Drawer.Root.Props,
"swipeDirection" | "snapPoints" | "snapPoint" | "defaultSnapPoint" | "onSnapPointChange"
> & {
/**
* Resting heights, smallest first. 0–1 is a fraction of the container (or
* viewport), larger numbers are px, strings take "px" or "rem".
*/
snapPoints?: SnapPoint[];
snapPoint?: SnapPoint | null;
defaultSnapPoint?: SnapPoint;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
};
/** Peek, half, full: a sheet that rests where the content has natural stops. */
export function BottomSheet({
snapPoints = ["148px", 0.55, 1],
snapPoint,
defaultSnapPoint,
onSnapPointChange,
onOpenChange,
...rest
}: BottomSheetProps) {
const initial = defaultSnapPoint ?? snapPoints[Math.min(1, snapPoints.length - 1)] ?? null;
const [snap, setSnap] = useControllableState<SnapPoint | null>({
value: snapPoint,
defaultValue: initial,
onChange: onSnapPointChange,
});
return (
<SheetCtx.Provider value={{ points: snapPoints, snap, setSnap }}>
<Drawer.Root
swipeDirection="down"
snapPoints={snapPoints}
snapPoint={snap}
onSnapPointChange={(next) => setSnap(next)}
onOpenChange={(open, details) => {
// Every opening starts from the default rest, not wherever it was dismissed from.
if (open) setSnap(initial);
onOpenChange?.(open, details);
}}
{...rest}
/>
</SheetCtx.Provider>
);
}
export type BottomSheetTriggerProps = Drawer.Trigger.Props;
export function BottomSheetTrigger(props: BottomSheetTriggerProps) {
return <Drawer.Trigger {...props} />;
}
export type BottomSheetContentProps = Omit<Drawer.Popup.Props, "className"> & {
className?: string;
/** Render into this element instead of document.body; snap fractions then measure the container. */
container?: Drawer.Portal.Props["container"];
/** Space left above the sheet at its full height, so the page behind still shows. */
topGap?: number;
};
export function BottomSheetContent({ container, topGap = 24, className, style, children, ...rest }: BottomSheetContentProps) {
const contained = container != null;
const { points, snap, setSnap } = useSheet();
const lastWheel = useRef(0);
return (
<Drawer.Portal container={container}>
<Drawer.Backdrop
className={cn(
contained ? "absolute" : "fixed",
"inset-0 z-(--z-overlay) bg-overlay",
// Dims fully only at the top snap point and lightens as the sheet comes down.
"opacity-[calc(1-var(--drawer-swipe-progress,0))] transition-opacity duration-[400ms] ease-drawer",
"data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength,1)*300ms)] data-swiping:duration-0",
)}
/>
<Drawer.Viewport
className={cn(contained ? "absolute" : "fixed", "inset-0 z-(--z-dialog) flex touch-none items-end justify-center overflow-hidden")}
>
<Drawer.Popup
style={{ ...(style as React.CSSProperties), "--top-gap": `${topGap}px` } as React.CSSProperties}
// Wheel down on a sheet that isn't fully open opens it a step, like a drag would.
onWheel={(e) => {
const i = points.findIndex((p) => Object.is(p, snap));
if (e.deltaY <= 4 || i < 0 || i >= points.length - 1) return;
const now = performance.now();
if (now - lastWheel.current < 450) return;
lastWheel.current = now;
setSnap(points[i + 1]);
}}
className={cn(
"group/sheet relative flex max-h-[calc(100%-var(--top-gap))] w-full max-w-[640px] flex-col rounded-t-2xl border border-b-0 border-line-2 bg-raised text-fg shadow-pop outline-none",
// Snap offset plus live drag; transitions only between rests, 1:1 while dragging.
"[transform:translateY(calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y,0px)))]",
"transition-transform duration-[400ms] ease-drawer data-swiping:duration-0 data-swiping:select-none",
"data-starting-style:[transform:translateY(100%)] data-ending-style:[transform:translateY(100%)]",
"data-ending-style:duration-[calc(var(--drawer-swipe-strength,1)*300ms)]",
// Fills the gap underneath when the sheet is pulled past the top and rubber-bands.
"after:pointer-events-none after:absolute after:inset-x-[-1px] after:top-full after:h-16 after:border-x after:border-line-2 after:bg-inherit after:content-['']",
className,
)}
{...rest}
>
{children}
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
);
}
export type BottomSheetHandleProps = Omit<React.ComponentProps<"div">, "children"> & {
/** How each snap point is announced, smallest first. */
snapLabels?: string[];
};
/**
* The grabber. Drag it like the rest of the sheet; tap or press Enter to step up
* through the snap points (wrapping to the smallest); arrows, Home and End move
* between them. It is a slider to assistive tech: “Sheet height, Half”.
*/
export function BottomSheetHandle({ snapLabels, className, onClick, onKeyDown, onPointerDown, ...rest }: BottomSheetHandleProps) {
const { points, snap, setSnap } = useSheet();
const i = Math.max(0, points.findIndex((p) => Object.is(p, snap)));
const last = points.length - 1;
const labels = snapLabels ?? (points.length === 3 ? ["Collapsed", "Half", "Expanded"] : points.map((_, n) => `${n + 1} of ${points.length}`));
const go = (n: number) => points.length && setSnap(points[Math.min(last, Math.max(0, n))]);
return (
<div
role="slider"
tabIndex={0}
aria-label="Sheet height"
aria-orientation="vertical"
aria-valuemin={0}
aria-valuemax={last}
aria-valuenow={i}
aria-valuetext={labels[i]}
onPointerDown={(e) => {
onPointerDown?.(e);
if (e.button !== 0) return;
const from = { x: e.clientX, y: e.clientY };
// The sheet captures the pointer for the drag, so listen for the release on the window.
// Only a tap steps; a drag already moved the sheet where it should be.
const up = (u: PointerEvent) => {
if (Math.hypot(u.clientX - from.x, u.clientY - from.y) <= 4) go(i === last ? 0 : i + 1);
};
window.addEventListener("pointerup", up, { once: true });
}}
onClick={(e) => {
onClick?.(e);
// Clicks synthesised by assistive tech (detail 0) step too.
if (!e.defaultPrevented && e.detail === 0) go(i === last ? 0 : i + 1);
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.defaultPrevented) return;
const next =
e.key === "ArrowUp" || e.key === "ArrowRight" ? i + 1
: e.key === "ArrowDown" || e.key === "ArrowLeft" ? i - 1
: e.key === "Home" ? 0
: e.key === "End" ? last
: e.key === "Enter" || e.key === " " ? (i === last ? 0 : i + 1)
: null;
if (next === null) return;
e.preventDefault();
go(next);
}}
className={cn(
"group/handle relative flex h-5 w-full shrink-0 cursor-grab touch-none select-none items-start justify-center pt-2 outline-none active:cursor-grabbing",
className,
)}
{...rest}
>
<span
aria-hidden
className={cn(
"h-[5px] w-9 rounded-full bg-fg-4 transition-[background-color,scale] duration-150 ease-out-quart",
"group-hover/handle:bg-fg-3 group-focus-visible/handle:bg-fg-2 group-focus-visible/handle:outline-solid group-focus-visible/handle:outline-1 group-focus-visible/handle:outline-offset-3 group-focus-visible/handle:outline-fg-3",
// The grabber widens a little while the sheet is held.
"group-active/handle:scale-x-110 group-data-swiping/sheet:scale-x-125 group-data-swiping/sheet:bg-fg-3",
)}
/>
</div>
);
}
export type BottomSheetHeaderProps = React.ComponentProps<"div">;
/** Sits under the handle and is the part you drag by; visible at every snap point. */
export function BottomSheetHeader({ className, ...rest }: BottomSheetHeaderProps) {
return <div className={cn("flex shrink-0 flex-col gap-0.5 px-5 pb-3 pt-2 select-none", className)} {...rest} />;
}
export type BottomSheetTitleProps = Omit<Drawer.Title.Props, "className"> & { className?: string };
export function BottomSheetTitle({ className, ...rest }: BottomSheetTitleProps) {
return <Drawer.Title className={cn("text-[15px] font-medium leading-[1.3] tracking-[-0.015em] text-fg", className)} {...rest} />;
}
export type BottomSheetDescriptionProps = Omit<Drawer.Description.Props, "className"> & { className?: string };
export function BottomSheetDescription({ className, ...rest }: BottomSheetDescriptionProps) {
return <Drawer.Description className={cn("text-[12.5px] leading-[1.5] text-fg-3", className)} {...rest} />;
}
export type BottomSheetBodyProps = Omit<Drawer.Content.Props, "className"> & { className?: string };
/**
* Scrolls only once the sheet is fully open. Below that a drag moves the sheet;
* at the top a drag scrolls the list, and pulling down from the top of the
* list hands the gesture back to the sheet. Keyboard focus inside opens it fully.
*/
export function BottomSheetBody({ className, onFocus, ...rest }: BottomSheetBodyProps) {
const { points, setSnap } = useSheet();
return (
<Drawer.Content
onFocus={(e) => {
onFocus?.(e);
if ((e.target as HTMLElement).matches?.(":focus-visible") && points.length) setSnap(points[points.length - 1]);
}}
className={cn(
"min-h-0 flex-1 overflow-y-hidden overscroll-contain border-t border-line px-5 pb-[max(16px,env(safe-area-inset-bottom))] pt-3",
"group-data-expanded/sheet:touch-pan-y group-data-expanded/sheet:overflow-y-auto",
className,
)}
{...rest}
/>
);
}
export type BottomSheetCloseProps = Omit<Drawer.Close.Props, "className"> & { className?: string };
export function BottomSheetClose({ className, ...rest }: BottomSheetCloseProps) {
return (
<Drawer.Close
className={cn(
"relative inline-flex h-8 shrink-0 select-none items-center justify-center whitespace-nowrap rounded-lg px-2.5 text-[12.5px] font-medium tracking-[-0.005em] text-fg-2",
"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
"transition-[background-color,color,scale] duration-150 ease-out-quart hover:bg-hover hover:text-fg active:scale-[0.97] active:duration-75",
"before:absolute before:-inset-x-1 before:-inset-y-1.5 before:content-[''] pointer-fine:before:hidden",
className,
)}
{...rest}
/>
);
}05Props
BottomSheet
| Prop | Type | Default | Description |
|---|---|---|---|
| snapPoints | (number | string)[] | ["148px", 0.55, 1] | Resting heights, smallest first: 0–1 is a fraction of the container, larger numbers are px, strings take px or rem. |
| snapPoint | number | string | null | — | Controlled snap point. Pair with onSnapPointChange. |
| defaultSnapPoint | number | string | the second point | Where it rests each time it opens. |
| onSnapPointChange | (snapPoint) => void | — | Called when a drag, the handle or the keyboard settles it somewhere new. |
| open | boolean | — | Controlled open state. Pair with onOpenChange. |
| modal | boolean | "trap-focus" | true | Use "trap-focus" when it lives inside a container so the page still scrolls. |
BottomSheetContent
| Prop | Type | Default | Description |
|---|---|---|---|
| container | HTMLElement | RefObject<HTMLElement> | — | Portal target. Fractional snap points then measure the container. |
| topGap | number | 24 | Room left above the sheet at full height, so the page still shows behind it. |
BottomSheetHandle
| Prop | Type | Default | Description |
|---|---|---|---|
| snapLabels | string[] | ["Collapsed", "Half", "Expanded"] | How each snap point is announced by the slider. |
BottomSheetHeader
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | Always visible, even at peek; a good place for the summary and Done. |
BottomSheetTitle
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | Names the sheet. |
BottomSheetDescription
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | One quiet line under the title. |
BottomSheetBody
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | The list. Scrolls only at the top snap point. |
BottomSheetClose
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | A quiet text button that closes the sheet. |
06Notes
Behavior
- Below the top snap point a drag anywhere moves the sheet; at the top the list scrolls, and pulling down from the top of the list hands the gesture back to the sheet.
- Releases pick the snap point by distance and speed, so a flick skips a stop or dismisses from peek. Pulling past full rubber-bands, and a strip under the sheet keeps the gap filled.
- The handle is also a control: tap or Enter steps up and wraps to peek, arrows move one stop, and keyboard focus inside the list opens the sheet fully. On desktop a downward wheel over a part-open sheet opens it a step.
- It reopens at its default snap point, not wherever it was dismissed from.
Motion
- Rests change over 400ms on the drawer curve (0.32, 0.72, 0, 1); while dragging it follows the pointer 1:1 with no easing.
- The backdrop dims fully only at the top snap point and tracks the drag in between, so peek and half leave the page readable.
- A release exits in up to 300ms, scaled by speed. The grabber widens 25% while the sheet is held.
- Reduced motion: rests and fades change instantly; dragging still follows the finger.
Accessibility
- role=dialog labeled by its title; focus is trapped and returns to the trigger. Escape closes.
- The handle is a slider (“Sheet height”) with aria-valuetext for each stop, so every gesture has a keyboard and screen-reader equivalent.
- Touch drags start anywhere, including on the handle; mouse drags start on the handle or header, so text in the list stays selectable.