Bottom Sheet
A mobile-style sheet you drag between snap points (screen fractions or auto height); a fast flick or long pull dismisses it. It portals to the body, pins the page so iOS cannot scroll underneath, and closes on Escape or a scrim tap.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @karaui/bottom-sheet
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/touch.ts
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/bottom-sheet.tsx
"use client";
// karaui.kamisama-v0.my.id/components/motion/bottom-sheet
import {
AnimatePresence,
motion,
type PanInfo,
useDragControls,
useReducedMotion,
} from "motion/react";
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { EASE_DRAWER } from "@/lib/ease";
import { TOUCH_GESTURE_CONTENT_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
// Vaul-style glide: a long, fully-damped tween reads smoother than a spring on
// open — no settle/overshoot, just one clean decel. Same curve drives the
// backdrop fade so the surface and scrim move as one.
const DRAWER = { duration: 0.5, ease: EASE_DRAWER } as const;
export interface BottomSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Heights (0-1 = fraction of viewport, or "auto"). First entry is default. */
snapPoints?: (number | "auto")[];
defaultSnap?: number;
title?: string;
description?: string;
children?: ReactNode;
className?: string;
/** Min drag distance (px) past current snap to dismiss. */
dismissThreshold?: number;
}
export function BottomSheet({
open,
onOpenChange,
snapPoints = [0.5, 0.92],
defaultSnap = 0,
title,
description,
children,
className,
dismissThreshold = 120,
}: BottomSheetProps) {
const [snap, setSnap] = useState(defaultSnap);
const [mounted, setMounted] = useState(false);
const dragControls = useDragControls();
const sheetRef = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const heightRef = useRef(0);
const uid = useId();
const titleId = `${uid}-title`;
const descriptionId = `${uid}-description`;
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (open) setSnap(defaultSnap);
}, [open, defaultSnap]);
// Lock background scroll while open. overflow:hidden alone is ignored by
// iOS Safari — boundary scrolls inside the sheet chain to the page, which
// scrolls underneath and ends up somewhere else on close. position:fixed
// is the lock that actually holds; restore the scroll position after.
useEffect(() => {
if (!open) return;
const body = document.body;
const scrollY = window.scrollY;
const prev = {
position: body.style.position,
top: body.style.top,
left: body.style.left,
right: body.style.right,
overflow: body.style.overflow,
};
body.style.position = "fixed";
body.style.top = `-${scrollY}px`;
body.style.left = "0";
body.style.right = "0";
body.style.overflow = "hidden";
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onOpenChange(false);
}
};
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
body.style.position = prev.position;
body.style.top = prev.top;
body.style.left = prev.left;
body.style.right = prev.right;
body.style.overflow = prev.overflow;
window.scrollTo(0, scrollY);
};
}, [open, onOpenChange]);
const onDragEnd = (_: unknown, info: PanInfo) => {
const velocity = info.velocity.y;
const offset = info.offset.y;
// Strong downward fling or large drag → dismiss.
if (velocity > 600 || offset > dismissThreshold) {
const smaller = snapPoints.map((_, i) => i).filter((i) => i < snap);
if (smaller.length && velocity < 800 && offset < dismissThreshold * 1.6) {
setSnap(smaller[smaller.length - 1]);
} else {
onOpenChange(false);
}
return;
}
// Strong upward fling → next snap.
if (velocity < -500) {
setSnap((current) => Math.min(snapPoints.length - 1, current + 1));
return;
}
// Otherwise snap to nearest by current offset.
setSnap((current) => {
if (offset > 80 && current > 0) return current - 1;
if (offset < -80 && current < snapPoints.length - 1) return current + 1;
return current;
});
};
const snapValue = snapPoints[snap];
const heightStyle =
snapValue === "auto"
? { maxHeight: "92vh" }
: { height: `${snapValue * 100}vh` };
// Portal to <body>: an ancestor with backdrop-filter or transform becomes
// the containing block for fixed descendants, which would position the
// sheet against that ancestor instead of the viewport.
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{open ? (
<div className="pointer-events-none fixed inset-0 z-50">
<motion.button
type="button"
aria-label="Close bottom sheet"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={DRAWER}
onClick={() => onOpenChange(false)}
// A dim scrim with a light blur. backdrop-blur is GPU-expensive and
// re-rasterizes every frame the sheet drags over it; a small radius
// plus more opacity keeps the glass look without the jank.
className="pointer-events-auto absolute inset-0 bg-background/40 backdrop-blur-sm"
/>
<motion.div
ref={sheetRef}
drag="y"
dragControls={dragControls}
dragListener={false}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0.02, bottom: 0.4 }}
dragMomentum={false}
onDragEnd={onDragEnd}
initial={reduce ? { y: 0, opacity: 0 } : { y: "100%" }}
animate={reduce ? { y: 0, opacity: 1 } : { y: 0 }}
exit={reduce ? { y: 0, opacity: 0 } : { y: "100%" }}
transition={reduce ? { duration: 0.18, ease: EASE_DRAWER } : DRAWER}
onAnimationComplete={() => {
if (sheetRef.current)
heightRef.current = sheetRef.current.offsetHeight;
}}
style={heightStyle}
className={cn(
"pointer-events-auto absolute bottom-0 left-0 right-0 mx-auto flex max-w-2xl flex-col overflow-hidden rounded-t-3xl will-change-transform",
"border border-border bg-background shadow-xl",
className,
)}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
aria-describedby={description ? descriptionId : undefined}
aria-label={title ? undefined : "Bottom sheet"}
>
<div className="flex flex-col items-center px-4 pb-2 pt-3">
{/* Drag only the pill so the title and description stay selectable. */}
<div
onPointerDown={(event) => dragControls.start(event)}
// A slow pull must not hand the gesture to iOS's callout,
// which would leave the sheet frozen mid-drag.
className={cn(
"flex cursor-grab touch-none items-center justify-center py-1 active:cursor-grabbing",
TOUCH_GESTURE_CONTENT_CLASS,
)}
>
<div className="h-1.5 w-10 rounded-full bg-muted-foreground/40" />
</div>
{title || description ? (
<div className="mt-2 w-full">
{title ? (
<h2
id={titleId}
className="text-base font-semibold text-foreground"
>
{title}
</h2>
) : null}
{description ? (
<p
id={descriptionId}
className="mt-0.5 text-sm text-muted-foreground"
>
{description}
</p>
) : null}
</div>
) : null}
</div>
{/* overscroll-contain stops boundary scrolls from chaining to the page. */}
<div className="flex-1 overflow-y-auto overscroll-contain px-4 pb-6">{children}</div>
</motion.div>
</div>
) : null}
</AnimatePresence>,
document.body,
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
openboolean—onOpenChange(open: boolean) => void—snapPoints?(number | "auto")[]Heights (0-1 = fraction of viewport, or "auto"). First entry is default.
[0.5, 0.92]defaultSnap?number0title?string—description?string—className?string—dismissThreshold?numberMin drag distance (px) past current snap to dismiss.
120Updated