cubby-ui
uidrawersource: ok · hit
Drawer
A drawer component.
npx shadcn add "https://www.cubby-ui.dev/r/drawer.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightclass-variance-authority@hugeicons/react@hugeicons/core-free-icons
Registry dependencies
@cubby-ui/button@cubby-ui/scroll-area@cubby-ui/elevated
Files6 · source cached 10/10/2026, 12:42:56 AM
registry/default/drawer/drawer.tsx
"use client";
import * as React from "react";
import {
Dialog as BaseDialog,
type DialogRootChangeEventDetails,
} from "@base-ui/react/dialog";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/default/button/button";
import {
INNER_EDGE_FROM_ATTACH_SIDE,
elevatedSurface,
flushSurface,
type SurfaceLevel,
} from "@/registry/default/lib/elevated";
import {
ScrollArea,
type ScrollAreaProps,
} from "@/registry/default/scroll-area/scroll-area";
import "./drawer.css";
import type { SnapPoint, DrawerDirection } from "./lib/drawer-utils";
import {
DIRECTION_CONFIG,
parsePixelValue,
findSnapPointIndex,
getSnapPointValue,
snapPointToRatio,
supportsScrollTimeline,
supportsScrollState,
} from "./lib/drawer-utils";
import { useScrollSnap } from "./hooks/use-scroll-snap";
import { useVirtualKeyboard } from "./hooks/use-virtual-keyboard";
import { useVisualViewportHeight } from "./hooks/use-visual-viewport-height";
import { HugeiconsIcon } from "@hugeicons/react";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
export type { SnapPoint, DrawerDirection };
const createDrawerHandle = BaseDialog.createHandle;
const drawerContentVariants = cva(
[
"text-popover-foreground flex flex-col",
"relative",
"ease-[cubic-bezier(.32,.72,0,1)] transition-[transform,scale,translate] duration-400 will-change-transform",
"motion-reduce:transition-none",
// Nested drawer support: scale down parent when child opens (interpolated during drag)
"scale-[calc(1-0.05*max(0,var(--nested-dialogs,0)-var(--nested-drag-progress,0)))]",
// Disable transitions on parent while child is being dragged
"data-[nested-dragging]:transition-none",
// Nested drawer support: overlay dim effect — uses ::before to stay clear of the elevation rim's ::after
"before:pointer-events-none before:absolute before:inset-0 before:z-50 before:hidden before:rounded-[inherit] before:bg-black/15 before:opacity-0 before:transition-[opacity,display] before:transition-discrete before:duration-300",
"data-nested-dialog-open:before:block data-nested-dialog-open:before:opacity-100",
"starting:data-nested-dialog-open:before:opacity-0",
],
{
variants: {
variant: {
// `default` no longer needs its own shadow/ring — the elevation system provides that
default: "",
floating: ["m-4 overflow-clip rounded-2xl"],
},
direction: {
bottom: "",
top: "",
right: "",
left: "",
},
},
compoundVariants: [
// Default variant - direction-specific sizing, rounding, and nesting origin/offset
{
variant: "default",
direction: "bottom",
class:
"origin-bottom mx-auto max-h-[95dvh] w-full max-w-full rounded-t-2xl -translate-y-[calc(1.5rem*max(0,var(--nested-dialogs,0)-var(--nested-drag-progress,0)))] [&[data-starting-style]]:translate-y-[var(--drawer-offset)] [&[data-ending-style]]:translate-y-[var(--drawer-offset)]",
},
{
variant: "default",
direction: "top",
class:
"origin-top mx-auto max-h-[95dvh] w-full max-w-full rounded-b-2xl translate-y-[calc(1.5rem*max(0,var(--nested-dialogs,0)-var(--nested-drag-progress,0)))] [&[data-starting-style]]:-translate-y-[var(--drawer-offset)] [&[data-ending-style]]:-translate-y-[var(--drawer-offset)]",
},
{
variant: "default",
direction: "right",
class:
"origin-right max-w-screen w-screen sm:max-w-md -translate-x-[calc(1.5rem*max(0,var(--nested-dialogs,0)-var(--nested-drag-progress,0)))] [&[data-starting-style]]:translate-x-[var(--drawer-offset)] [&[data-ending-style]]:translate-x-[var(--drawer-offset)]",
},
{
variant: "default",
direction: "left",
class:
"origin-left max-w-screen w-screen sm:max-w-md translate-x-[calc(1.5rem*max(0,var(--nested-dialogs,0)-var(--nested-drag-progress,0)))] [&[data-starting-style]]:-translate-x-[var(--drawer-offset)] [&[data-ending-style]]:-translate-x-[var(--drawer-offset)]",
},
// Floating variant - direction-specific sizing, transforms, and nesting origin/offset
{
variant: "floating",
direction: "bottom",
class:
"origin-bottom mx-auto max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] -translate-y-[calc(1.5rem*max(0,var(--nested-dialogs,0)-var(--nested-drag-progress,0)))] [&[data-starting-style]]:translate-y-[var(--drawer-offset)] [&[data-ending-style]]:translate-y-[var(--drawer-offset)]",
},
{
variant: "floating",
direction: "top",
class:
"origin-top mx-auto max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] translate-y-[calc(1.5rem*max(0,var(--nested-dialogs,0)-var(--nested-drag-progress,0)))] [&[data-starting-style]]:-translate-y-[var(--drawer-offset)] [&[data-ending-style]]:-translate-y-[var(--drawer-offset)]",
},
{
variant: "floating",
direction: "right",
class:
"origin-right h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-[calc(100vw-2rem)] sm:max-w-md -translate-x-[calc(1.5rem*max(0,var(--nested-dialogs,0)-var(--nested-drag-progress,0)))] [&[data-starting-style]]:translate-x-[var(--drawer-offset)] [&[data-ending-style]]:translate-x-[var(--drawer-offset)]",
},
{
variant: "floating",
direction: "left",
class:
"origin-left h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-[calc(100vw-2rem)] sm:max-w-md translate-x-[calc(1.5rem*max(0,var(--nested-dialogs,0)-var(--nested-drag-progress,0)))] [&[data-starting-style]]:-translate-x-[var(--drawer-offset)] [&[data-ending-style]]:-translate-x-[var(--drawer-offset)]",
},
],
defaultVariants: {
variant: "default",
direction: "bottom",
},
},
);
const drawerTrackVariants = cva("pointer-events-none relative flex", {
variants: {
direction: {
bottom: "w-full flex-col justify-end",
top: "w-full flex-col justify-start",
right: "h-full flex-row justify-end",
left: "h-full flex-row justify-start",
},
},
defaultVariants: {
direction: "bottom",
},
});
// iOS 26 Safari: Fixed elements for nav bar color detection (must be within 3px of edge, ≥80% wide, ≥3px tall)
const SafariNavColorDetectors = (
<>
<div
aria-hidden="true"
className="bg-popover pointer-events-none fixed inset-x-0 bottom-0 hidden h-10 bg-clip-text [@supports(-webkit-touch-callout:none)]:block"
/>
<div
aria-hidden="true"
className="bg-popover pointer-events-none fixed inset-x-0 top-0 hidden h-10 bg-clip-text [@supports(-webkit-touch-callout:none)]:block"
/>
</>
);
const backdropAnimationStyles: Record<DrawerDirection, string> = {
bottom:
"fill-mode-[both] [animation-name:drawer-backdrop-fade] [animation-range:entry_0%_entry_100%] [animation-timeline:--drawer-panel] [animation-timing-function:linear]",
right:
"fill-mode-[both] [animation-name:drawer-backdrop-fade] [animation-range:entry_0%_entry_100%] [animation-timeline:--drawer-panel] [animation-timing-function:linear]",
top: "fill-mode-[both] direction-[reverse] [animation-name:drawer-backdrop-fade] [animation-range:exit_0%_exit_100%] [animation-timeline:--drawer-panel] [animation-timing-function:linear]",
left: "fill-mode-[both] direction-[reverse] [animation-name:drawer-backdrop-fade] [animation-range:exit_0%_exit_100%] [animation-timeline:--drawer-panel] [animation-timing-function:linear]",
};
// Allows child drawers to propagate drag progress to parent for smooth stacking effects.
interface DrawerNestingContextValue {
/** Set the child's drag progress (0 = open, 1 = closed) on the parent popup */
setNestedDragProgress: (progress: number) => void;
/** Toggle transition-disabling attribute on the parent popup */
setNestedDragging: (dragging: boolean) => void;
/** Remove the drag progress CSS variable from the parent popup */
clearNestedDragProgress: () => void;
}
const DrawerNestingContext =
React.createContext<DrawerNestingContextValue | null>(null);
type DrawerVariant = "default" | "floating";
interface DrawerConfigContextValue {
direction: DrawerDirection;
variant: DrawerVariant;
snapPoints: SnapPoint[];
dismissible: boolean;
modal: boolean | "trap-focus";
isVertical: boolean;
sequentialSnap: boolean;
repositionInputs: boolean;
}
interface DrawerScrollContextValue {
isDragging: boolean;
dragProgress: number;
snapProgress: number;
}
interface DrawerControlContextValue {
open: boolean;
onOpenChange: (
open: boolean,
eventDetails?: DialogRootChangeEventDetails,
) => void;
dismissViaSwipe: () => void;
activeSnapPoint: SnapPoint;
setActiveSnapPoint: (snapPoint: SnapPoint) => void;
contentSize: number | null;
setContentSize: (size: number | null) => void;
isAnimating: boolean;
immediateClose: boolean;
setImmediateClose: (value: boolean) => void;
setIsDragging: (dragging: boolean) => void;
setDragProgress: (progress: number) => void;
setSnapProgress: (progress: number) => void;
}
const DrawerConfigContext =
React.createContext<DrawerConfigContextValue | null>(null);
const DrawerScrollContext =
React.createContext<DrawerScrollContextValue | null>(null);
const DrawerControlContext =
React.createContext<DrawerControlContextValue | null>(null);
function useDrawerConfig() {
const context = React.useContext(DrawerConfigContext);
if (!context) {
throw new Error("Drawer components must be used within a <Drawer />");
}
return context;
}
/** Subscribes to high-frequency scroll/drag state — re-renders during drag. */
function useDrawerScroll() {
const context = React.useContext(DrawerScrollContext);
if (!context) {
throw new Error("Drawer components must be used within a <Drawer />");
}
return context;
}
/** Low-frequency control state and stable setters — does not re-render during drag. */
function useDrawerControl() {
const context = React.useContext(DrawerControlContext);
if (!context) {
throw new Error("Drawer components must be used within a <Drawer />");
}
return context;
}
/** Combines scroll + control contexts. For fewer re-renders use `useDrawerControl()` or `useDrawerScroll()`. */
function useDrawerAnimation() {
return { ...useDrawerScroll(), ...useDrawerControl() };
}
/** All drawer context values. For fewer re-renders use `useDrawerConfig`, `useDrawerControl`, or `useDrawerScroll`. */
function useDrawer() {
return { ...useDrawerConfig(), ...useDrawerScroll(), ...useDrawerControl() };
}
interface DrawerRenderProps {
/** 0 = first snap, 1 = last snap */
snapProgress: number;
/** 0 = open, 1 = closed */
dragProgress: number;
isDragging: boolean;
activeSnapPoint: SnapPoint;
}
interface DrawerProps extends Omit<
React.ComponentProps<typeof BaseDialog.Root>,
"children" | "onOpenChange"
> {
direction?: DrawerDirection;
variant?: DrawerVariant;
/** Percentages (0-1) or pixel strings (e.g., "200px") */
snapPoints?: SnapPoint[];
defaultSnapPoint?: SnapPoint;
activeSnapPoint?: SnapPoint | null;
onActiveSnapPointChange?: (snapPoint: SnapPoint) => void;
dismissible?: boolean;
/** `true` = modal, `"trap-focus"` = focus trapped but no scroll lock, `false` = non-modal */
modal?: boolean | "trap-focus";
/** Prevents skipping snap points during fast swipes */
sequentialSnap?: boolean;
/** Repositions drawer when virtual keyboard appears (bottom only) */
repositionInputs?: boolean;
/**
* Event handler called when the drawer is opened or closed.
* `eventDetails` may be undefined for internal close actions (e.g., DrawerClose, DrawerHandle).
*/
onOpenChange?: (
open: boolean,
eventDetails?: DialogRootChangeEventDetails,
) => void;
children?: React.ReactNode | ((props: DrawerRenderProps) => React.ReactNode);
}
const DEFAULT_SNAP_POINTS: SnapPoint[] = [1];
function Drawer({
direction = "bottom",
variant = "default",
snapPoints = DEFAULT_SNAP_POINTS,
defaultSnapPoint,
activeSnapPoint: controlledSnapPoint,
onActiveSnapPointChange,
dismissible = true,
modal = true,
sequentialSnap = false,
repositionInputs = false,
open: controlledOpen,
defaultOpen,
onOpenChange: controlledOnOpenChange,
children,
...props
}: DrawerProps) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
defaultOpen ?? false,
);
const isOpenControlled = controlledOpen !== undefined;
const open = isOpenControlled ? controlledOpen : uncontrolledOpen;
const defaultSnapPointIndex =
defaultSnapPoint !== undefined
? findSnapPointIndex(snapPoints, defaultSnapPoint)
: 0;
const [internalSnapPointIndex, setInternalSnapPointIndex] = React.useState(
defaultSnapPointIndex,
);
const isSnapPointControlled = controlledSnapPoint !== undefined;
const activeSnapPointIndex = isSnapPointControlled
? findSnapPointIndex(snapPoints, controlledSnapPoint)
: internalSnapPointIndex;
const activeSnapPointValue = getSnapPointValue(
snapPoints,
activeSnapPointIndex,
);
const [isDragging, setIsDragging] = React.useState(false);
const [dragProgress, setDragProgress] = React.useState(1);
const [snapProgress, setSnapProgress] = React.useState(0);
const [contentSize, setContentSize] = React.useState<number | null>(null);
const [isAnimating, setIsAnimating] = React.useState(false);
const [immediateClose, setImmediateClose] = React.useState(false);
// Base UI doesn't call onOpenChange for external prop changes, so handleOpenChange(true)
// never fires — set isAnimating here to block dismiss gestures during the enter transition.
const prevOpenRef = React.useRef(open);
React.useLayoutEffect(() => {
if (open && !prevOpenRef.current) {
setIsAnimating(true);
setImmediateClose(false);
}
prevOpenRef.current = open;
}, [open]);
const { isVertical } = DIRECTION_CONFIG[direction];
const isDraggingRef = React.useRef(false);
const swipeDismissRef = React.useRef(false);
React.useLayoutEffect(() => {
isDraggingRef.current = isDragging;
});
const handleOpenChangeComplete = React.useCallback(() => {
setIsAnimating(false);
setImmediateClose(false);
}, []);
const handleOpenChange = React.useCallback(
(nextOpen: boolean, eventDetails?: DialogRootChangeEventDetails) => {
// Prevent closing while scrolling, but allow explicit swipe dismiss
if (!nextOpen && isDraggingRef.current && !swipeDismissRef.current) {
return;
}
swipeDismissRef.current = false;
setIsAnimating(true);
if (!isOpenControlled) {
setUncontrolledOpen(nextOpen);
}
controlledOnOpenChange?.(nextOpen, eventDetails);
if (nextOpen && !isSnapPointControlled) {
setInternalSnapPointIndex(defaultSnapPointIndex);
const defaultSnapValue = getSnapPointValue(
snapPoints,
defaultSnapPointIndex,
);
onActiveSnapPointChange?.(defaultSnapValue);
}
if (nextOpen) {
setDragProgress(1);
setIsDragging(false);
setImmediateClose(false);
if (!isSnapPointControlled) {
const progress =
snapPoints.length > 1
? defaultSnapPointIndex / (snapPoints.length - 1)
: 0;
setSnapProgress(progress);
}
}
},
[
isOpenControlled,
controlledOnOpenChange,
snapPoints,
isSnapPointControlled,
onActiveSnapPointChange,
defaultSnapPointIndex,
],
);
const dismissViaSwipe = React.useCallback(() => {
swipeDismissRef.current = true;
handleOpenChange(false);
}, [handleOpenChange]);
const setActiveSnapPoint = React.useCallback(
(value: SnapPoint) => {
const index = findSnapPointIndex(snapPoints, value);
if (!isSnapPointControlled) {
setInternalSnapPointIndex(index);
}
onActiveSnapPointChange?.(value);
},
[snapPoints, isSnapPointControlled, onActiveSnapPointChange],
);
const configValue = React.useMemo(
() => ({
direction,
variant,
snapPoints,
dismissible,
modal,
isVertical,
sequentialSnap,
repositionInputs,
}),
[
direction,
variant,
snapPoints,
dismissible,
modal,
isVertical,
sequentialSnap,
repositionInputs,
],
);
const scrollValue = React.useMemo(
() => ({
isDragging,
dragProgress,
snapProgress,
}),
[isDragging, dragProgress, snapProgress],
);
const controlValue = React.useMemo(
() => ({
open,
onOpenChange: handleOpenChange,
dismissViaSwipe,
activeSnapPoint: activeSnapPointValue,
setActiveSnapPoint,
contentSize,
setContentSize,
isAnimating,
immediateClose,
setImmediateClose,
setIsDragging,
setDragProgress,
setSnapProgress,
}),
[
open,
handleOpenChange,
dismissViaSwipe,
activeSnapPointValue,
setActiveSnapPoint,
contentSize,
isAnimating,
immediateClose,
],
);
const resolvedChildren =
typeof children === "function"
? children({
snapProgress,
dragProgress,
isDragging,
activeSnapPoint: activeSnapPointValue,
})
: children;
return (
<DrawerConfigContext.Provider value={configValue}>
<DrawerScrollContext.Provider value={scrollValue}>
<DrawerControlContext.Provider value={controlValue}>
<BaseDialog.Root
data-slot="drawer"
open={open}
onOpenChange={handleOpenChange}
onOpenChangeComplete={handleOpenChangeComplete}
modal={modal}
disablePointerDismissal={isAnimating || modal !== true}
{...props}
>
{resolvedChildren}
</BaseDialog.Root>
</DrawerControlContext.Provider>
</DrawerScrollContext.Provider>
</DrawerConfigContext.Provider>
);
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof BaseDialog.Trigger>) {
return <BaseDialog.Trigger data-slot="drawer-trigger" {...props} />;
}
interface DrawerCloseProps extends useRender.ComponentProps<"button"> {
onClick?: (event: React.MouseEvent) => void;
}
function DrawerClose({
onClick,
className,
render,
...props
}: DrawerCloseProps) {
const { onOpenChange, isAnimating } = useDrawerControl();
const handleClick = React.useCallback(
(event: React.MouseEvent) => {
onClick?.(event);
if (event.defaultPrevented) return;
if (isAnimating) return;
onOpenChange(false);
},
[onClick, onOpenChange, isAnimating],
);
const defaultProps = {
"data-slot": "drawer-close",
type: "button" as const,
className,
onClick: handleClick,
};
const element = useRender({
defaultTagName: "button",
render,
props: mergeProps<"button">(defaultProps, props),
});
return element;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof BaseDialog.Portal>) {
return <BaseDialog.Portal data-slot="drawer-portal" {...props} />;
}
interface DrawerContentProps extends BaseDialog.Popup.Props {
footerVariant?: "default" | "inset";
showCloseButton?: boolean;
/** Surface elevation level for the drawer bg (1-8). Defaults to 5 — the dialog/sheet/drawer tier. */
level?: SurfaceLevel;
/** Shadow weight (1-8). Pinned to 5 by default. */
shadowLevel?: SurfaceLevel;
}
function DrawerContent({
initialFocus,
finalFocus,
footerVariant = "default",
showCloseButton,
level = 5,
shadowLevel = 5,
...props
}: DrawerContentProps) {
return (
<DrawerPortal>
<DrawerContentInner
initialFocus={initialFocus}
finalFocus={finalFocus}
footerVariant={footerVariant}
showCloseButton={showCloseButton}
level={level}
shadowLevel={shadowLevel}
{...props}
/>
</DrawerPortal>
);
}
function useContentMeasurement(
isVertical: boolean,
floatingMargin: number,
setContentSize: (size: number | null) => void,
) {
const observerRef = React.useRef<ResizeObserver | null>(null);
const measureRef = React.useCallback(
(node: HTMLDivElement | null) => {
observerRef.current?.disconnect();
observerRef.current = null;
if (!node) return;
const measure = () => {
const baseSize = isVertical ? node.offsetHeight : node.offsetWidth;
setContentSize(baseSize + floatingMargin);
};
measure();
observerRef.current = new ResizeObserver(measure);
observerRef.current.observe(node);
},
[isVertical, setContentSize, floatingMargin],
);
const popupRef = React.useRef<HTMLDivElement>(null);
const mergedRef = React.useCallback(
(node: HTMLDivElement | null) => {
popupRef.current = node;
measureRef(node);
},
[measureRef],
);
React.useEffect(() => () => observerRef.current?.disconnect(), []);
return { mergedRef, popupRef };
}
function useDrawerContentStyles({
direction,
variant,
activeSnapPoint,
snapPoints,
contentSize,
isVertical,
isInitialized,
isAnimating,
immediateClose,
dismissible,
snapProgress,
repositionInputs,
keyboardHeight,
visualViewportHeight,
}: {
direction: DrawerDirection;
variant: DrawerVariant;
activeSnapPoint: SnapPoint;
snapPoints: SnapPoint[];
contentSize: number | null;
isVertical: boolean;
isInitialized: boolean;
isAnimating: boolean;
immediateClose: boolean;
dismissible: boolean;
snapProgress: number;
repositionInputs: boolean;
keyboardHeight: number;
visualViewportHeight: number | null;
}) {
const snapPointRatio = React.useMemo(() => {
if (typeof activeSnapPoint === "number") {
return activeSnapPoint;
}
const pixels = parsePixelValue(activeSnapPoint);
if (!pixels || !contentSize) return 1;
return pixels / contentSize;
}, [activeSnapPoint, contentSize]);
const firstSnapRatio =
contentSize != null
? snapPointToRatio(snapPoints[0], contentSize)
: typeof snapPoints[0] === "number"
? snapPoints[0]
: 1;
const lastSnapRatio =
contentSize != null
? snapPointToRatio(snapPoints[snapPoints.length - 1], contentSize)
: typeof snapPoints[snapPoints.length - 1] === "number"
? snapPoints[snapPoints.length - 1]
: 1;
const baseOffset =
typeof activeSnapPoint === "number"
? `${activeSnapPoint * 100}%`
: activeSnapPoint;
const animationOffset =
variant === "floating" ? `calc(${baseOffset} + 1rem)` : baseOffset;
const targetBackdropOpacity = snapPointRatio;
const useScrollDrivenAnimation =
supportsScrollTimeline && isInitialized && !isAnimating && !immediateClose;
const viewportStyle = React.useMemo<React.CSSProperties>(
() => ({
...(visualViewportHeight != null && {
"--visual-viewport-height": `${visualViewportHeight}px`,
}),
...(repositionInputs && {
"--keyboard-height": `${keyboardHeight}px`,
}),
"--content-size": `${contentSize ?? 0}px`,
"--dismiss-buffer": dismissible ? `${(contentSize ?? 0) * 0.3}px` : "0px",
"--first-snap-ratio": firstSnapRatio,
"--last-snap-ratio": lastSnapRatio,
scrollSnapType: isInitialized
? isVertical
? "y mandatory"
: "x mandatory"
: "none",
scrollBehavior: isInitialized ? "smooth" : "auto",
...(useScrollDrivenAnimation
? {
animationName: "drawer-snap-progress",
animationTimingFunction: "linear",
animationFillMode: "both",
animationTimeline: isVertical ? "scroll(self)" : "scroll(self x)",
...(direction === "top" || direction === "left"
? {
animationRange: `calc(var(--content-size) * (1 - var(--last-snap-ratio))) calc(var(--content-size) * (1 - var(--first-snap-ratio)))`,
animationDirection: "reverse",
}
: {
animationRange: `calc(var(--dismiss-buffer) + var(--first-snap-ratio) * var(--content-size)) calc(var(--dismiss-buffer) + var(--last-snap-ratio) * var(--content-size))`,
}),
}
: {
"--drawer-snap-progress": snapProgress,
}),
}),
[
visualViewportHeight,
repositionInputs,
keyboardHeight,
contentSize,
dismissible,
firstSnapRatio,
lastSnapRatio,
isInitialized,
isVertical,
useScrollDrivenAnimation,
snapProgress,
direction,
],
);
const popupStyle = React.useMemo<React.CSSProperties>(
() => ({
"--drawer-offset": animationOffset,
...(supportsScrollTimeline && {
viewTimelineName: "--drawer-panel",
viewTimelineAxis: isVertical ? "block" : "inline",
}),
}),
[animationOffset, isVertical],
);
return {
viewportStyle,
popupStyle,
useScrollDrivenAnimation,
targetBackdropOpacity,
};
}
function DrawerContentInner({
className,
children,
footerVariant = "default",
showCloseButton = false,
initialFocus,
finalFocus,
level = 5,
shadowLevel = 5,
...props
}: DrawerContentProps) {
const {
direction,
variant,
snapPoints,
dismissible,
modal,
isVertical,
sequentialSnap,
repositionInputs,
} = useDrawerConfig();
const { isDragging, dragProgress, snapProgress } = useDrawerScroll();
const {
activeSnapPoint,
setActiveSnapPoint,
contentSize,
setContentSize,
setIsDragging,
setDragProgress,
setSnapProgress,
dismissViaSwipe,
open,
isAnimating,
immediateClose,
setImmediateClose,
} = useDrawerControl();
const activeSnapPointIndex = findSnapPointIndex(snapPoints, activeSnapPoint);
const handleSnapPointChange = React.useCallback(
(index: number) => {
setActiveSnapPoint(getSnapPointValue(snapPoints, index));
},
[snapPoints, setActiveSnapPoint],
);
const { keyboardHeight, isKeyboardVisible } = useVirtualKeyboard({
enabled: direction === "bottom",
});
const visualViewportHeight = useVisualViewportHeight({
enabled: !isVertical && modal !== true,
});
const handleDismiss = React.useCallback(() => {
dismissViaSwipe();
}, [dismissViaSwipe]);
const handleImmediateClose = React.useCallback(() => {
setImmediateClose(true);
}, [setImmediateClose]);
const parentNesting = React.useContext(DrawerNestingContext);
// Skip updates during enter/exit animations — let CSS control the backdrop then.
const handleScrollProgress = React.useCallback(
(progress: number) => {
if (!isAnimating) {
setDragProgress(progress);
// Update parent's CSS variable for interpolated nesting scale/translate
parentNesting?.setNestedDragProgress(progress);
}
},
[isAnimating, setDragProgress, parentNesting],
);
const handleSnapProgress = React.useCallback(
(progress: number) => {
if (!isAnimating) {
setSnapProgress(progress);
}
},
[isAnimating, setSnapProgress],
);
const {
containerRef,
isScrolling,
setSnapTargetRef,
trackSize,
snapScrollPositions,
isInitialized,
isClosing,
} = useScrollSnap({
direction,
snapPoints,
activeSnapPointIndex,
onSnapPointChange: handleSnapPointChange,
onDismiss: handleDismiss,
dismissible,
contentSize,
open,
onScrollProgress: handleScrollProgress,
onSnapProgress: handleSnapProgress,
onImmediateClose: handleImmediateClose,
isAnimating,
onScrollingChange: setIsDragging,
});
React.useLayoutEffect(() => {
parentNesting?.setNestedDragging(isDragging);
}, [isDragging, parentNesting]);
React.useLayoutEffect(() => {
if (!open) {
parentNesting?.clearNestedDragProgress();
parentNesting?.setNestedDragging(false);
}
}, [open, parentNesting]);
const floatingMargin = variant === "floating" ? 16 : 0;
const { mergedRef, popupRef } = useContentMeasurement(
isVertical,
floatingMargin,
setContentSize,
);
const nestingValue = React.useMemo<DrawerNestingContextValue>(
() => ({
setNestedDragProgress(progress: number) {
popupRef.current?.style.setProperty(
"--nested-drag-progress",
String(progress),
);
},
setNestedDragging(dragging: boolean) {
if (!popupRef.current) return;
if (dragging) {
popupRef.current.dataset.nestedDragging = "";
} else {
delete popupRef.current.dataset.nestedDragging;
}
},
clearNestedDragProgress() {
popupRef.current?.style.removeProperty("--nested-drag-progress");
},
}),
[popupRef],
);
const {
viewportStyle,
popupStyle,
useScrollDrivenAnimation,
targetBackdropOpacity,
} = useDrawerContentStyles({
direction,
variant,
activeSnapPoint,
snapPoints,
contentSize,
isVertical,
isInitialized,
isAnimating,
immediateClose,
dismissible,
snapProgress,
repositionInputs,
keyboardHeight,
visualViewportHeight,
});
return (
<DrawerNestingContext.Provider value={nestingValue}>
<div
data-slot="drawer-timeline-scope"
style={
supportsScrollTimeline
? ({ timelineScope: "--drawer-panel" } as React.CSSProperties)
: undefined
}
>
{modal === true && (
<BaseDialog.Backdrop
data-slot="drawer-overlay"
className={cn(
"absolute inset-0 z-50 bg-black/32 backdrop-blur-sm",
"[transform:translateZ(0)] will-change-[opacity]",
isClosing ? "pointer-events-none" : "pointer-events-auto",
"touch-none",
immediateClose || (isDragging && !isAnimating)
? "transition-none"
: "transition-opacity duration-300",
"[&[data-starting-style]]:opacity-0!",
// Exit animation overrides scroll-driven animation — transitions can't interpolate from animation-held values.
"data-ending-style:animate-[drawer-backdrop-exit_300ms_cubic-bezier(.32,.72,0,1)_forwards]",
isInitialized && !isAnimating && dismissible && dragProgress < 1
? useScrollDrivenAnimation
? backdropAnimationStyles[direction]
: `opacity-(--drawer-backdrop-dynamic-opacity)`
: `opacity-(--drawer-backdrop-static-opacity)`,
)}
style={
{
"--drawer-backdrop-dynamic-opacity": 1 - dragProgress,
"--drawer-backdrop-static-opacity": targetBackdropOpacity,
} as React.CSSProperties
}
/>
)}
<BaseDialog.Viewport
ref={containerRef}
data-slot="drawer-viewport"
data-direction={direction}
data-scrolling={isScrolling || undefined}
data-keyboard-visible={
direction === "bottom" && repositionInputs && isKeyboardVisible
? "true"
: undefined
}
className={cn(
"group/drawer",
"fixed inset-x-0 z-50 outline-hidden",
// Bottom: -60px top buffer prevents URL bar collapse; non-modal uses lvh
direction === "bottom" &&
(modal === true
? "top-[-60px] bottom-[env(keyboard-inset-height,var(--keyboard-height,0))]"
: "top-auto! bottom-[env(keyboard-inset-height,var(--keyboard-height,0))] h-lvh"),
direction === "top" && "top-0! bottom-[-60px]!",
!isVertical &&
(modal === true
? "top-0! bottom-0! h-dvh"
: "top-0! bottom-0 h-lvh"),
isAnimating || isClosing || modal !== true
? "pointer-events-none"
: "pointer-events-auto",
"bg-transparent opacity-100! [&[data-ending-style]]:opacity-100! [&[data-starting-style]]:opacity-100!",
"[scrollbar-width:none]! [&::-webkit-scrollbar]:hidden!",
isAnimating || isClosing
? "touch-none overflow-hidden"
: isVertical
? "touch-pan-y overflow-x-hidden overflow-y-auto overscroll-y-none"
: "touch-pan-x overflow-x-auto overflow-y-hidden overscroll-x-none",
isVertical ? "touch-pan-y" : "touch-pan-x",
"motion-reduce:[scroll-behavior:auto]",
"has-data-nested-dialog-open:touch-none has-data-nested-dialog-open:overflow-hidden",
)}
style={viewportStyle}
>
<div
data-slot="drawer-track"
className={drawerTrackVariants({ direction })}
style={
{
[isVertical ? "height" : "width"]: `${trackSize}px`,
"--content-size": `${contentSize ?? 0}px`,
"--dismiss-buffer": dismissible
? `${(contentSize ?? 0) * 0.3}px`
: "0px",
} as React.CSSProperties
}
>
{/* Snap targets with JS-calculated positions (CSS calc() has issues on iOS Safari) */}
{snapScrollPositions.map((position, index) => (
<div
key={index}
ref={(el) => setSnapTargetRef(index, el)}
data-slot="drawer-snap-target"
data-snap-index={index}
className={cn(
"pointer-events-none absolute",
isVertical ? "inset-x-0 h-px" : "inset-y-0 w-px",
)}
style={
{
[isVertical ? "top" : "left"]: `${position}px`,
scrollSnapAlign: "start",
scrollSnapStop: sequentialSnap ? "always" : undefined,
...(supportsScrollState && {
containerType: "scroll-state",
}),
} as React.CSSProperties
}
aria-hidden="true"
/>
))}
<BaseDialog.Popup
ref={mergedRef}
data-slot="drawer-content"
data-footer-variant={footerVariant}
data-level={level}
initialFocus={initialFocus ?? popupRef}
finalFocus={finalFocus}
className={cn(
drawerContentVariants({ variant, direction }),
// Floating: full 4-edge rim. Flush (`default`): single-edge rim on the
// inner-facing side only — other edges would show a 1px line at the viewport boundary.
variant === "floating"
? elevatedSurface(level, shadowLevel)
: flushSurface(level, INNER_EDGE_FROM_ATTACH_SIDE[direction]),
open && !isInitialized && "opacity-0",
isAnimating || isClosing
? "pointer-events-none"
: "pointer-events-auto",
immediateClose && "transition-none",
"data-nested-dialog-open:overflow-hidden",
// Safari iOS touch fix (WebKit bug #183870): the popup needs a sliver of
// cross-axis scroll so iOS engages its touch handler. The overflow itself
// is supplied by the shim <span> below, keeping the rim's ::after to itself.
modal !== true && [
"[@supports(-webkit-touch-callout:none)]:relative [@supports(-webkit-touch-callout:none)]:[scrollbar-width:none]",
isVertical
? "[@supports(-webkit-touch-callout:none)]:overflow-x-scroll [@supports(-webkit-touch-callout:none)]:overscroll-x-none"
: "[@supports(-webkit-touch-callout:none)]:overflow-y-scroll [@supports(-webkit-touch-callout:none)]:overscroll-y-none",
],
className,
)}
style={popupStyle}
{...props}
>
{/* Safari shim: supplies the 1px cross-axis scroll overflow (see the WebKit
fix above) as a child element so the elevation rim keeps the popup's
::after to itself. Inert (display:none) on every engine except iOS Safari. */}
{modal !== true && (
<span
aria-hidden
className={cn(
"hidden [@supports(-webkit-touch-callout:none)]:pointer-events-none [@supports(-webkit-touch-callout:none)]:absolute [@supports(-webkit-touch-callout:none)]:inset-0 [@supports(-webkit-touch-callout:none)]:block",
isVertical
? "[@supports(-webkit-touch-callout:none)]:w-[calc(100%+0.5px)]"
: "[@supports(-webkit-touch-callout:none)]:h-[calc(100%+1px)]",
)}
/>
)}
{children}
{showCloseButton && (
<DrawerClose
aria-label="Close"
className="absolute end-2 top-2"
render={<Button size="icon_sm" variant="ghost" />}
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
</DrawerClose>
)}
</BaseDialog.Popup>
</div>
{SafariNavColorDetectors}
</BaseDialog.Viewport>
</div>
</DrawerNestingContext.Provider>
);
}
interface DrawerHandleProps extends Omit<
useRender.ComponentProps<"button">,
"children"
> {
hidden?: boolean;
preventClose?: boolean;
}
function DrawerHandle({
className,
hidden,
preventClose = false,
onClick,
render,
...props
}: DrawerHandleProps) {
const { isVertical, direction } = useDrawerConfig();
const { onOpenChange, isAnimating } = useDrawerControl();
const isHorizontalDrawer = direction === "left" || direction === "right";
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
if (isAnimating || preventClose) return;
onOpenChange(false);
},
[onClick, onOpenChange, isAnimating, preventClose],
);
const defaultProps = {
"data-slot": "drawer-handle" as const,
type: "button" as const,
"aria-label": preventClose ? "Drawer handle" : "Close drawer",
onClick: handleClick,
className: cn(
"appearance-none border-0 bg-transparent p-0",
"focus-visible:outline-ring/50 rounded-sm outline-0 outline-offset-0 outline-transparent outline-solid focus-visible:outline-2 focus-visible:outline-offset-2",
"bg-muted-foreground/30 shrink-0 cursor-pointer rounded-full",
isVertical ? "mx-auto my-3 h-1 w-12" : "mx-3 my-auto h-12 w-1",
"hover:bg-muted-foreground/50 transition-[color,opacity]",
// Fade out when nested drawer opens, fade back when nested drawer is being dragged
// "in-data-nested-dialog-open:opacity-0 in-data-nested-dragging:opacity-100",
className,
),
};
const element = useRender({
defaultTagName: "button",
render,
props: mergeProps<"button">(defaultProps, props),
});
if (hidden || isHorizontalDrawer) return null;
return element;
}
// Padding rules use ancestor `:has()` on `[data-slot=drawer-content]` instead of
// adjacent-sibling selectors so consumers can wrap slots in forms, Suspense, etc.
// without breaking padding, and so a DrawerHandle between slots doesn't silently
// drop the header's padding (sibling selectors would miss it).
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-2 px-6 pt-6 pb-3",
"in-data-[direction=bottom]:in-[[data-slot=drawer-content]:has([data-slot=drawer-handle])]:pt-1",
// Header alone (with or without footer, no body): expand bottom padding.
"in-[[data-slot=drawer-content]:not(:has([data-slot=drawer-body])):has([data-slot=drawer-footer])]:pb-6",
"in-[[data-slot=drawer-content]:not(:has([data-slot=drawer-body])):not(:has([data-slot=drawer-footer]))]:pb-6",
// "transition-opacity in-data-nested-dialog-open:opacity-0 in-data-nested-dragging:opacity-100",
className,
)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn(
// z-1 above DrawerBody(z-0); translateZ(0) is Safari-only GPU promotion to fix
// sticky/transform compositing flash during enter animation.
"z-1 mt-auto flex flex-col-reverse gap-2 bg-(--popup-surface,var(--popover)) px-6 pt-4 pb-6 sm:flex-row sm:justify-end",
"[@supports(-webkit-touch-callout:none)]:transform-[translateZ(0)]",
"in-[[data-slot=drawer-content]:not(:has([data-slot=drawer-header])):not(:has([data-slot=drawer-body]))]:pt-6",
"not-in-data-[footer-variant=inset]:in-[[data-slot=drawer-content]:has([data-slot=drawer-body])]:pt-3",
"in-data-[footer-variant=inset]:border-border in-data-[footer-variant=inset]:bg-muted in-data-[footer-variant=inset]:border-t in-data-[footer-variant=inset]:pt-4 in-data-[footer-variant=inset]:pb-4",
// "transition-opacity in-data-nested-dialog-open:opacity-0 in-data-nested-dragging:opacity-100",
className,
)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof BaseDialog.Title>) {
return (
<BaseDialog.Title
data-slot="drawer-title"
className={cn(
"text-foreground text-lg leading-none font-semibold tracking-tight text-balance",
className,
)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof BaseDialog.Description>) {
return (
<BaseDialog.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm text-pretty", className)}
{...props}
/>
);
}
function DrawerBody({
className,
nativeScroll = false,
fadeEdges = false,
scrollbarGutter = false,
persistScrollbar,
hideScrollbar,
scrollLock = false,
children,
...props
}: React.ComponentProps<"div"> & {
nativeScroll?: boolean;
/** Disable scrolling when the drawer is not at its last (full) snap point */
scrollLock?: boolean;
} & Pick<
ScrollAreaProps,
"fadeEdges" | "scrollbarGutter" | "persistScrollbar" | "hideScrollbar"
>) {
const { isVertical, snapPoints } = useDrawerConfig();
const { activeSnapPoint } = useDrawerControl();
const isAtFullSnap = activeSnapPoint === snapPoints[snapPoints.length - 1];
return (
<div
data-slot="drawer-body"
className={cn(
// z-0: stays below sticky footer during iOS Safari enter animation.
"relative z-0 flex min-h-0 flex-1 flex-col overflow-hidden",
"in-[[data-slot=drawer-content]:not(:has([data-slot=drawer-header]))]:pt-5",
"in-[[data-slot=drawer-content]:not(:has([data-slot=drawer-footer]))]:pb-5",
// Inset footer: still pad body bottom so content doesn't crowd the footer's top border.
"in-data-[footer-variant=inset]:in-[[data-slot=drawer-content]:has([data-slot=drawer-footer])]:pb-5",
// "transition-opacity in-data-nested-dialog-open:opacity-0 in-data-nested-dragging:opacity-100",
)}
>
<ScrollArea
className="flex-1"
fadeEdges={fadeEdges}
scrollbarGutter={scrollbarGutter}
persistScrollbar={persistScrollbar}
hideScrollbar={hideScrollbar}
nativeScroll={nativeScroll}
overscrollBehavior="auto"
viewportClassName={cn(
isVertical ? "touch-pan-y" : "touch-pan-x",
scrollLock && !isAtFullSnap && "!overflow-hidden",
)}
>
<div className={cn("px-6 py-1", className)} {...props}>
{children}
</div>
</ScrollArea>
</div>
);
}
export {
Drawer,
DrawerTrigger,
DrawerClose,
DrawerPortal,
DrawerContent,
DrawerHandle,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
DrawerBody,
useDrawer,
useDrawerConfig,
useDrawerAnimation,
useDrawerScroll,
useDrawerControl,
createDrawerHandle,
};
export type { DrawerRenderProps, DrawerVariant };
export { supportsScrollTimeline, supportsScrollState };
registry/default/drawer/lib/drawer-utils.ts
/* -------------------------------------------------------------------------------------------------
* Types
* -------------------------------------------------------------------------------------------------*/
export type DrawerDirection = "top" | "right" | "bottom" | "left";
/**
* Snap point value:
* - number (0-1): percentage of drawer visible (0 = closed, 1 = fully open)
* - `${number}px`: fixed pixel height visible (e.g., "200px")
*/
export type SnapPoint = number | `${number}px`;
/**
* Direction configuration for drawer behavior.
* Eliminates repeated conditionals throughout the codebase.
*/
export const DIRECTION_CONFIG = {
top: { isVertical: true, isInverted: true },
bottom: { isVertical: true, isInverted: false },
left: { isVertical: false, isInverted: true },
right: { isVertical: false, isInverted: false },
} as const;
export type DirectionConfig = (typeof DIRECTION_CONFIG)[DrawerDirection];
/**
* Scroll geometry for drawer positioning calculations.
*/
export interface ScrollGeometry {
/** Total track size (viewport + content + dismiss buffer) */
trackSize: number;
/** Buffer space for dismiss gesture */
dismissBuffer: number;
/** Maximum scroll position */
maxScroll: number;
/** Whether scroll direction is inverted (top/left) */
isInverted: boolean;
}
/* -------------------------------------------------------------------------------------------------
* Browser Support Detection
* -------------------------------------------------------------------------------------------------*/
export const supportsScrollEnd =
typeof window !== "undefined" && "onscrollend" in window;
/**
* Feature detection for scroll-driven animations (animation-timeline: scroll())
* Chrome 115+, Safari 26+ (future), Firefox flag-only
*/
export const supportsScrollTimeline =
typeof CSS !== "undefined" &&
CSS.supports("animation-timeline", "scroll()") &&
CSS.supports("timeline-scope", "--test");
/**
* Feature detection for scroll snap events (scrollsnapchange, scrollsnapchanging)
* Chrome 129+ only
*/
export const supportsScrollSnapChange =
typeof window !== "undefined" && "onscrollsnapchange" in window;
/**
* Feature detection for CSS scroll-state() container queries
* Chrome 133+ only
*/
export const supportsScrollState =
typeof CSS !== "undefined" && CSS.supports("container-type", "scroll-state");
/* -------------------------------------------------------------------------------------------------
* Utility Functions
* -------------------------------------------------------------------------------------------------*/
/**
* Parse a pixel value string (e.g., "200px") and return the number
*/
export function parsePixelValue(value: string): number | null {
const match = value.match(/^(\d+(?:\.\d+)?)px$/);
return match ? parseFloat(match[1]) : null;
}
/**
* Convert a snap point to a ratio (0-1).
* For percentage snap points, returns the value directly.
* For pixel snap points, divides by contentSize.
*/
export function snapPointToRatio(
snapPoint: SnapPoint,
contentSize: number,
): number {
if (typeof snapPoint === "number") {
return snapPoint;
}
const pixels = parsePixelValue(snapPoint);
return pixels != null ? pixels / contentSize : 1;
}
/**
* Find the index of a snap point value in the array
* Returns the last index if value is null or not found
*/
export function findSnapPointIndex(
snapPoints: SnapPoint[],
value: SnapPoint | null,
): number {
if (value === null) return snapPoints.length - 1;
const index = snapPoints.findIndex((sp) => sp === value);
return index === -1 ? snapPoints.length - 1 : index;
}
/**
* Get the snap point value at a given index (clamped to valid range)
*/
export function getSnapPointValue(
snapPoints: SnapPoint[],
index: number,
): SnapPoint {
return snapPoints[Math.max(0, Math.min(index, snapPoints.length - 1))];
}
/**
* Wait for scroll to end on an element
*/
export function waitForScrollEnd(element: HTMLElement): Promise<void> {
return new Promise((resolve) => {
if (supportsScrollEnd) {
element.addEventListener("scrollend", () => resolve(), { once: true });
} else {
// Fallback: debounced scroll detection
let timeout: ReturnType<typeof setTimeout>;
const handler = () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
element.removeEventListener("scroll", handler);
resolve();
}, 0);
};
element.addEventListener("scroll", handler, { passive: true });
}
});
}
/**
* Check if user prefers reduced motion
*/
export function prefersReducedMotion(): boolean {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
/* -------------------------------------------------------------------------------------------------
* Scroll Geometry Calculations
* -------------------------------------------------------------------------------------------------*/
/**
* Calculate scroll geometry for drawer positioning.
* These values determine track size and scroll positions for snap points.
*/
export function calculateScrollGeometry(
viewportSize: number,
contentSize: number,
dismissible: boolean,
isInverted: boolean,
): ScrollGeometry {
// Caller is responsible for ensuring contentSize is valid (> 0)
const dismissBuffer = dismissible ? contentSize * 0.3 : 0;
const trackSize = viewportSize + contentSize + dismissBuffer;
const maxScroll = trackSize - viewportSize;
return {
trackSize,
dismissBuffer,
maxScroll,
isInverted,
};
}
/**
* Calculate scroll positions for each snap point.
* Returns an array where index 0 is dismiss position (if dismissible),
* followed by positions for each snap point.
*/
export function calculateSnapScrollPositions(
snapPoints: SnapPoint[],
geometry: ScrollGeometry,
dismissible: boolean,
contentSize: number,
): number[] {
const { maxScroll, dismissBuffer, isInverted } = geometry;
const positions: number[] = [];
// Add dismiss position first if dismissible
if (dismissible) {
positions.push(isInverted ? maxScroll : 0);
}
// Calculate positions for each snap point
// Note: contentSize here is effectiveSize (with fallback applied)
for (const snapPoint of snapPoints) {
const visibleRatio =
typeof snapPoint === "string"
? (parsePixelValue(snapPoint) ?? contentSize) / contentSize
: snapPoint;
// Calculate scroll position based on direction
// Formula: dismissBuffer + visibleRatio * contentSize (non-inverted)
// Or: maxScroll - dismissBuffer - visibleRatio * contentSize (inverted)
const scrollPos = isInverted
? maxScroll - dismissBuffer - visibleRatio * contentSize
: dismissBuffer + visibleRatio * contentSize;
positions.push(Math.min(maxScroll, Math.max(0, scrollPos)));
}
return positions;
}
/**
* Calculate progress from scroll position (0 = fully open, 1 = fully closed).
* Used for backdrop opacity animation.
*/
export function calculateScrollProgress(
scrollPos: number,
geometry: ScrollGeometry,
contentSize: number,
): number {
const { dismissBuffer, isInverted } = geometry;
let progress: number;
if (isInverted) {
// Top/Left: scroll 0 = open (0), maxScroll = closed (1)
// openScrollPos simplifies to 0 (maxScroll - dismissBuffer - contentSize = 0)
progress = scrollPos / contentSize;
} else {
// Bottom/Right: 0 = closed (1), maxScroll = open (0)
progress = 1 - (scrollPos - dismissBuffer) / contentSize;
}
return Math.min(1, Math.max(0, progress));
}
/**
* Calculate snap progress from scroll position (0 = first snap, 1 = last snap).
* Used for crossfade effects between snap points.
*/
export function calculateSnapProgress(
scrollPos: number,
snapScrollPositions: number[],
dismissible: boolean,
): number {
const firstSnapIndex = dismissible ? 1 : 0;
const lastSnapIndex = snapScrollPositions.length - 1;
// Handle edge cases
if (firstSnapIndex >= lastSnapIndex) return 0;
const firstSnapPos = snapScrollPositions[firstSnapIndex];
const lastSnapPos = snapScrollPositions[lastSnapIndex];
if (firstSnapPos === lastSnapPos) return 0;
const progress =
(scrollPos - firstSnapPos) / (lastSnapPos - firstSnapPos);
return Math.min(1, Math.max(0, progress));
}
registry/default/drawer/hooks/use-scroll-snap.ts
import * as React from "react";
import type {
DrawerDirection,
SnapPoint,
ScrollGeometry,
} from "../lib/drawer-utils";
import {
DIRECTION_CONFIG,
supportsScrollEnd,
supportsScrollTimeline,
supportsScrollState,
supportsScrollSnapChange,
prefersReducedMotion,
waitForScrollEnd,
calculateScrollGeometry,
calculateSnapScrollPositions,
calculateScrollProgress,
calculateSnapProgress,
} from "../lib/drawer-utils";
export interface UseScrollSnapOptions {
direction: DrawerDirection;
snapPoints: SnapPoint[];
activeSnapPointIndex: number;
onSnapPointChange: (index: number) => void;
onDismiss: () => void;
dismissible: boolean;
contentSize: number | null;
open: boolean;
/** 0 = open, 1 = closed */
onScrollProgress?: (progress: number) => void;
/** 0 = first snap, 1 = last snap */
onSnapProgress?: (progress: number) => void;
onImmediateClose?: () => void;
isAnimating?: boolean;
onScrollingChange?: (isScrolling: boolean) => void;
}
export interface UseScrollSnapReturn {
containerRef: React.RefObject<HTMLDivElement | null>;
isScrolling: boolean;
setSnapTargetRef: (index: number, el: HTMLDivElement | null) => void;
trackSize: number;
snapScrollPositions: number[];
isInitialized: boolean;
isClosing: boolean;
}
interface ScrollControlState {
isProgrammatic: boolean;
lastDetectedSnapIndex: number;
isFromDetection: boolean;
}
interface InteractionState {
isClosing: boolean;
isPointerDown: boolean;
prevScrollPos: number | null;
/** Firefox: click stops momentum scroll, need to track if scrollend fired while pointer down */
scrollEndedWhilePointerDown: boolean;
}
interface InitState {
hasInitialized: boolean;
retryTimeout: ReturnType<typeof setTimeout> | null;
rafId: number | null;
rafLastPos: number | null;
rafStableCount: number;
}
export { supportsScrollTimeline, supportsScrollState };
export function useScrollSnap(
options: UseScrollSnapOptions,
): UseScrollSnapReturn {
const {
direction,
snapPoints,
activeSnapPointIndex,
dismissible,
contentSize,
open,
onScrollingChange,
} = options;
// Get direction config (replaces repeated conditionals)
const { isVertical, isInverted } = DIRECTION_CONFIG[direction];
const containerRef = React.useRef<HTMLDivElement | null>(null);
const snapTargetRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const scrollControlRef = React.useRef<ScrollControlState>({
isProgrammatic: false,
lastDetectedSnapIndex: activeSnapPointIndex,
isFromDetection: false,
});
const checkScrollStabilityRef = React.useRef<() => void>(() => {});
const interactionRef = React.useRef<InteractionState>({
isClosing: false,
isPointerDown: false,
prevScrollPos: null,
scrollEndedWhilePointerDown: false,
});
const initRef = React.useRef<InitState>({
hasInitialized: false,
retryTimeout: null,
rafId: null,
rafLastPos: null,
rafStableCount: 0,
});
const optionsRef = React.useRef(options);
React.useLayoutEffect(() => {
optionsRef.current = options;
});
const [isScrolling, setIsScrolling] = React.useState(false);
const [isClosing, setIsClosingState] = React.useState(false);
const [isInitialized, setIsInitialized] = React.useState(false);
const [viewportSize, setViewportSize] = React.useState(() => {
if (typeof window === "undefined") return 800;
return isVertical ? window.innerHeight : window.innerWidth;
});
const setIsClosing = React.useCallback((value: boolean) => {
interactionRef.current.isClosing = value;
setIsClosingState(value);
}, []);
const updateIsScrolling = React.useCallback(
(value: boolean) => {
setIsScrolling(value);
onScrollingChange?.(value);
},
[onScrollingChange],
);
// Fallback size until measured (prevents iOS Safari timing issues)
const effectiveSize = contentSize ?? viewportSize * 0.9;
const geometry = React.useMemo<ScrollGeometry>(
() =>
calculateScrollGeometry(
viewportSize,
effectiveSize,
dismissible,
isInverted,
),
[viewportSize, effectiveSize, dismissible, isInverted],
);
const snapScrollPositions = React.useMemo(
() =>
calculateSnapScrollPositions(
snapPoints,
geometry,
dismissible,
effectiveSize,
),
[snapPoints, geometry, dismissible, effectiveSize],
);
const getScrollPositionForSnapPoint = React.useCallback(
(index: number): number => {
const adjustedIndex = dismissible ? index + 1 : index;
return snapScrollPositions[adjustedIndex] ?? 0;
},
[snapScrollPositions, dismissible],
);
const findNearestSnapIndex = React.useCallback(
(scrollPos: number): { index: number; isDismiss: boolean } => {
let closestIndex = 0;
let closestDistance = Math.abs(
scrollPos - (snapScrollPositions[0] ?? 0),
);
for (let i = 1; i < snapScrollPositions.length; i++) {
const distance = Math.abs(scrollPos - snapScrollPositions[i]);
if (distance < closestDistance) {
closestDistance = distance;
closestIndex = i;
}
}
if (dismissible && closestIndex === 0) {
return { index: 0, isDismiss: true };
}
const snapIndex = dismissible ? closestIndex - 1 : closestIndex;
return { index: Math.max(0, snapIndex), isDismiss: false };
},
[snapScrollPositions, dismissible],
);
const detectAndNotifySnapChange = React.useCallback(
(scrollPos: number) => {
if (
scrollControlRef.current.isProgrammatic ||
interactionRef.current.isClosing
) {
return;
}
const { index, isDismiss } = findNearestSnapIndex(scrollPos);
if (
!isDismiss &&
index !== scrollControlRef.current.lastDetectedSnapIndex
) {
scrollControlRef.current.lastDetectedSnapIndex = index;
scrollControlRef.current.isFromDetection = true;
optionsRef.current.onSnapPointChange(index);
}
},
[findNearestSnapIndex],
);
const scrollToSnapPoint = React.useCallback(
(index: number, behavior: ScrollBehavior = "smooth") => {
const container = containerRef.current;
if (!container) return;
const scrollPos = getScrollPositionForSnapPoint(index);
const actualBehavior = prefersReducedMotion() ? "auto" : behavior;
scrollControlRef.current.isProgrammatic = true;
container.scrollTo({
[isVertical ? "top" : "left"]: scrollPos,
behavior: actualBehavior,
});
if (actualBehavior === "auto") {
scrollControlRef.current.isProgrammatic = false;
} else {
waitForScrollEnd(container).then(() => {
scrollControlRef.current.isProgrammatic = false;
});
}
},
[getScrollPositionForSnapPoint, isVertical],
);
const triggerImmediateDismiss = React.useCallback(() => {
if (interactionRef.current.isClosing) return;
const container = containerRef.current;
if (container) {
container.style.overflow = "hidden";
container.style.pointerEvents = "none";
container.style.touchAction = "none";
}
setIsClosing(true);
scrollControlRef.current.isProgrammatic = true;
optionsRef.current.onImmediateClose?.();
optionsRef.current.onDismiss();
}, [setIsClosing]);
// Fallback for browsers without scrollend event
const checkScrollStability = React.useCallback(() => {
const container = containerRef.current;
if (!container) return;
const { isVertical: isVert } =
DIRECTION_CONFIG[optionsRef.current.direction];
const currentPos = isVert ? container.scrollTop : container.scrollLeft;
const { rafLastPos } = initRef.current;
if (rafLastPos !== null && Math.abs(currentPos - rafLastPos) < 0.5) {
initRef.current.rafStableCount++;
// 3 stable frames (~50ms) = scroll ended (if not touching)
if (
initRef.current.rafStableCount >= 3 &&
!interactionRef.current.isPointerDown
) {
updateIsScrolling(false);
detectAndNotifySnapChange(currentPos);
initRef.current.rafId = null;
initRef.current.rafLastPos = null;
initRef.current.rafStableCount = 0;
return;
}
} else {
initRef.current.rafStableCount = 0;
}
initRef.current.rafLastPos = currentPos;
initRef.current.rafId = requestAnimationFrame(
checkScrollStabilityRef.current,
);
}, [updateIsScrolling, detectAndNotifySnapChange]);
React.useLayoutEffect(() => {
checkScrollStabilityRef.current = checkScrollStability;
});
const startScrollStabilityCheck = React.useCallback(() => {
if (initRef.current.rafId === null) {
initRef.current.rafLastPos = null;
initRef.current.rafStableCount = 0;
initRef.current.rafId = requestAnimationFrame(checkScrollStability);
}
}, [checkScrollStability]);
const handleScroll = React.useCallback(() => {
if (!initRef.current.hasInitialized) return;
const container = containerRef.current;
if (!container) return;
const { isVertical: isVert } =
DIRECTION_CONFIG[optionsRef.current.direction];
const scrollPos = isVert ? container.scrollTop : container.scrollLeft;
const progress = calculateScrollProgress(scrollPos, geometry, effectiveSize);
optionsRef.current.onScrollProgress?.(progress);
const snapProg = calculateSnapProgress(
scrollPos,
snapScrollPositions,
optionsRef.current.dismissible,
);
optionsRef.current.onSnapProgress?.(snapProg);
// 2px threshold filters scroll-snap micro-adjustments
const positionChanged =
interactionRef.current.prevScrollPos !== null &&
Math.abs(scrollPos - interactionRef.current.prevScrollPos) > 2;
interactionRef.current.prevScrollPos = scrollPos;
if (!scrollControlRef.current.isProgrammatic && positionChanged) {
updateIsScrolling(true);
if (!supportsScrollEnd) {
startScrollStabilityCheck();
}
}
if (!scrollControlRef.current.isProgrammatic) {
if (
optionsRef.current.dismissible &&
!interactionRef.current.isClosing &&
progress >= 1
) {
triggerImmediateDismiss();
}
}
}, [
effectiveSize,
geometry,
snapScrollPositions,
updateIsScrolling,
startScrollStabilityCheck,
triggerImmediateDismiss,
]);
const handleScrollEnd = React.useCallback(() => {
// Firefox: clicking stops momentum, fires scrollend while pointer down
if (interactionRef.current.isPointerDown) {
interactionRef.current.scrollEndedWhilePointerDown = true;
} else {
updateIsScrolling(false);
}
const container = containerRef.current;
if (container) {
const { isVertical: isVert } =
DIRECTION_CONFIG[optionsRef.current.direction];
const scrollPos = isVert ? container.scrollTop : container.scrollLeft;
detectAndNotifySnapChange(scrollPos);
}
}, [updateIsScrolling, detectAndNotifySnapChange]);
const handleScrollSnapChange = React.useCallback((event: Event) => {
if (interactionRef.current.isClosing) return;
const snapEvent = event as Event & {
snapTargetBlock?: Element | null;
snapTargetInline?: Element | null;
};
const { isVertical: isVert } =
DIRECTION_CONFIG[optionsRef.current.direction];
const snapTarget = isVert
? snapEvent.snapTargetBlock
: snapEvent.snapTargetInline;
if (!snapTarget) return;
const snapIndexAttr = snapTarget.getAttribute("data-snap-index");
if (snapIndexAttr === null) return;
const rawIndex = parseInt(snapIndexAttr, 10);
if (isNaN(rawIndex)) return;
const actualIndex = optionsRef.current.dismissible
? rawIndex - 1
: rawIndex;
if (actualIndex < 0) return;
if (actualIndex !== scrollControlRef.current.lastDetectedSnapIndex) {
scrollControlRef.current.lastDetectedSnapIndex = actualIndex;
scrollControlRef.current.isFromDetection = true;
optionsRef.current.onSnapPointChange(actualIndex);
}
}, []);
// Touch events only (not mouse) - iOS Safari has quirky pointer event behavior
const handleTouchStart = React.useCallback(() => {
interactionRef.current.isPointerDown = true;
}, []);
const handleTouchEnd = React.useCallback(() => {
interactionRef.current.isPointerDown = false;
initRef.current.rafStableCount = 0;
// Firefox: if scrollend fired while pointer down, start stability check
if (interactionRef.current.scrollEndedWhilePointerDown) {
interactionRef.current.scrollEndedWhilePointerDown = false;
startScrollStabilityCheck();
}
}, [startScrollStabilityCheck]);
// Stable handler refs — keeps the event listener effect from re-running
// when handler dependencies change (geometry, snapScrollPositions, etc.)
const handleScrollRef = React.useRef(handleScroll);
const handleScrollEndRef = React.useRef(handleScrollEnd);
const handleScrollSnapChangeRef = React.useRef(handleScrollSnapChange);
const handleTouchStartRef = React.useRef(handleTouchStart);
const handleTouchEndRef = React.useRef(handleTouchEnd);
React.useLayoutEffect(() => {
handleScrollRef.current = handleScroll;
handleScrollEndRef.current = handleScrollEnd;
handleScrollSnapChangeRef.current = handleScrollSnapChange;
handleTouchStartRef.current = handleTouchStart;
handleTouchEndRef.current = handleTouchEnd;
});
// Initialization (DrawerContentInner unmounts on close, giving fresh state)
React.useEffect(() => {
if (!open) {
if (initRef.current.rafId !== null) {
cancelAnimationFrame(initRef.current.rafId);
initRef.current.rafId = null;
}
if (initRef.current.retryTimeout) {
clearTimeout(initRef.current.retryTimeout);
initRef.current.retryTimeout = null;
}
return;
}
if (contentSize === null) return;
if (!initRef.current.hasInitialized) {
const performInitialScroll = () => {
const container = containerRef.current;
if (!container) {
initRef.current.retryTimeout = setTimeout(performInitialScroll, 0);
return;
}
const size = isVertical
? container.clientHeight
: container.clientWidth;
if (size === 0) {
initRef.current.retryTimeout = setTimeout(performInitialScroll, 0);
return;
}
initRef.current.hasInitialized = true;
if (size !== viewportSize) {
setViewportSize(size);
}
const targetIndex = dismissible
? activeSnapPointIndex + 1
: activeSnapPointIndex;
const targetScrollPos = snapScrollPositions[targetIndex] ?? 0;
scrollControlRef.current.isProgrammatic = true;
scrollControlRef.current.lastDetectedSnapIndex = activeSnapPointIndex;
if (isVertical) {
container.scrollTop = targetScrollPos;
} else {
container.scrollLeft = targetScrollPos;
}
const initialProgress = calculateScrollProgress(
targetScrollPos,
geometry,
effectiveSize,
);
optionsRef.current.onScrollProgress?.(initialProgress);
const initialSnapProgress = calculateSnapProgress(
targetScrollPos,
snapScrollPositions,
dismissible,
);
optionsRef.current.onSnapProgress?.(initialSnapProgress);
setTimeout(() => {
scrollControlRef.current.isProgrammatic = false;
}, 0);
setIsInitialized(true);
};
initRef.current.retryTimeout = setTimeout(performInitialScroll, 0);
}
const currentInit = initRef.current;
return () => {
if (currentInit.retryTimeout) {
clearTimeout(currentInit.retryTimeout);
currentInit.retryTimeout = null;
}
};
}, [
open,
contentSize,
effectiveSize,
isVertical,
viewportSize,
dismissible,
activeSnapPointIndex,
snapScrollPositions,
geometry,
]);
// Sync lastDetectedSnapIndex to prevent false isFromDetection after programmatic scrolls
React.useEffect(() => {
if (open) {
scrollControlRef.current.lastDetectedSnapIndex = activeSnapPointIndex;
}
}, [open, activeSnapPointIndex]);
// Handle controlled snap point changes
const prevSnapPointRef = React.useRef(activeSnapPointIndex);
React.useEffect(() => {
if (prevSnapPointRef.current !== activeSnapPointIndex && open) {
if (scrollControlRef.current.isFromDetection) {
scrollControlRef.current.isFromDetection = false;
prevSnapPointRef.current = activeSnapPointIndex;
return;
}
const container = containerRef.current;
if (container) {
const currentScrollPos = isVertical
? container.scrollTop
: container.scrollLeft;
const targetScrollPos =
getScrollPositionForSnapPoint(activeSnapPointIndex);
if (Math.abs(currentScrollPos - targetScrollPos) > 10) {
scrollToSnapPoint(activeSnapPointIndex, "smooth");
}
}
}
prevSnapPointRef.current = activeSnapPointIndex;
}, [
activeSnapPointIndex,
open,
scrollToSnapPoint,
isVertical,
getScrollPositionForSnapPoint,
]);
// Re-position when geometry changes (e.g., keyboard appears)
const prevTrackSizeRef = React.useRef(0);
React.useEffect(() => {
if (!open || !initRef.current.hasInitialized) return;
const { trackSize } = geometry;
if (
prevTrackSizeRef.current !== 0 &&
prevTrackSizeRef.current !== trackSize
) {
const container = containerRef.current;
if (container && !scrollControlRef.current.isProgrammatic) {
const targetScrollPos =
getScrollPositionForSnapPoint(activeSnapPointIndex);
const prevScrollBehavior = container.style.scrollBehavior;
container.style.scrollBehavior = "auto";
if (isVertical) {
container.scrollTop = targetScrollPos;
} else {
container.scrollLeft = targetScrollPos;
}
container.style.scrollBehavior = prevScrollBehavior;
}
}
prevTrackSizeRef.current = trackSize;
}, [
open,
geometry,
isVertical,
activeSnapPointIndex,
getScrollPositionForSnapPoint,
]);
React.useEffect(() => {
const container = containerRef.current;
if (!container || !open) return;
// Stable wrappers dispatch through refs so handler dep changes
// don't tear down and re-register all listeners.
const onScroll = () => handleScrollRef.current();
const onScrollEnd = () => handleScrollEndRef.current();
const onScrollSnapChange = (e: Event) =>
handleScrollSnapChangeRef.current(e);
const onTouchStart = () => handleTouchStartRef.current();
const onTouchEnd = () => handleTouchEndRef.current();
container.addEventListener("scroll", onScroll, { passive: true });
container.addEventListener("touchstart", onTouchStart, { passive: true });
container.addEventListener("touchend", onTouchEnd, { passive: true });
container.addEventListener("touchcancel", onTouchEnd, { passive: true });
if (supportsScrollEnd) {
container.addEventListener("scrollend", onScrollEnd);
}
if (supportsScrollSnapChange) {
container.addEventListener("scrollsnapchange", onScrollSnapChange);
}
const updateViewportSize = () => {
const size = isVertical ? container.clientHeight : container.clientWidth;
setViewportSize(size);
};
window.addEventListener("resize", updateViewportSize);
return () => {
container.removeEventListener("scroll", onScroll);
container.removeEventListener("touchstart", onTouchStart);
container.removeEventListener("touchend", onTouchEnd);
container.removeEventListener("touchcancel", onTouchEnd);
if (supportsScrollEnd) {
container.removeEventListener("scrollend", onScrollEnd);
}
if (supportsScrollSnapChange) {
container.removeEventListener("scrollsnapchange", onScrollSnapChange);
}
window.removeEventListener("resize", updateViewportSize);
};
}, [open, isVertical]);
const setSnapTargetRef = React.useCallback(
(index: number, el: HTMLDivElement | null) => {
snapTargetRefs.current[index] = el;
},
[],
);
return {
containerRef,
isScrolling,
setSnapTargetRef,
trackSize: geometry.trackSize,
snapScrollPositions,
isInitialized,
isClosing,
};
}
registry/default/drawer/hooks/use-virtual-keyboard.ts
import * as React from "react";
export interface UseVirtualKeyboardOptions {
/** Whether keyboard handling is enabled */
enabled?: boolean;
}
export interface UseVirtualKeyboardReturn {
/** Current keyboard height in pixels (0 when closed) */
keyboardHeight: number;
/** Whether the virtual keyboard is currently visible */
isKeyboardVisible: boolean;
}
/**
* Helper to check if element is an input that would trigger a virtual keyboard.
*/
function isInput(element: HTMLElement | null): boolean {
if (!element) return false;
const tagName = element.tagName.toLowerCase();
return (
tagName === "input" ||
tagName === "textarea" ||
tagName === "select" ||
element.isContentEditable
);
}
/**
* Detect Firefox mobile which natively handles keyboard repositioning.
* We skip our custom transform on Firefox to avoid double-repositioning.
*/
const isFirefoxMobile =
typeof navigator !== "undefined" &&
/Firefox/i.test(navigator.userAgent) &&
/Android|Mobile/i.test(navigator.userAgent);
/**
* Hook to detect virtual keyboard visibility and height using the Visual Viewport API.
*
* On mobile devices, when a virtual keyboard appears, it reduces the visual viewport height.
* This hook tracks that change so components can adjust their layout accordingly.
*
* Simplified approach:
* - Keyboard is visible if viewport is significantly smaller (>100px) AND an input is focused
* - No complex toggle state that can get out of sync
*/
export function useVirtualKeyboard({
enabled = true,
}: UseVirtualKeyboardOptions = {}): UseVirtualKeyboardReturn {
const [keyboardHeight, setKeyboardHeight] = React.useState(0);
React.useEffect(() => {
// Firefox mobile natively handles keyboard repositioning, so we skip
// our custom detection to avoid double-repositioning the drawer
if (!enabled || typeof window === "undefined" || isFirefoxMobile) return;
// Enable Virtual Keyboard API to get env(keyboard-inset-*) CSS variables
// This tells the browser that we'll handle the virtual keyboard geometry ourselves
if ("virtualKeyboard" in navigator) {
(navigator as Navigator & { virtualKeyboard: { overlaysContent: boolean } })
.virtualKeyboard.overlaysContent = true;
}
const visualViewport = window.visualViewport;
if (!visualViewport) return;
const handleResize = () => {
const focusedElement = document.activeElement as HTMLElement;
const isInputFocused = isInput(focusedElement);
// If no input is focused, keyboard can't be open for our purposes
if (!isInputFocused) {
setKeyboardHeight(0);
return;
}
const visualViewportHeight = visualViewport.height;
const totalHeight = window.innerHeight;
const diff = totalHeight - visualViewportHeight;
// Keyboard is visible if viewport is significantly smaller (>100px threshold)
// This avoids false positives from address bar changes
if (diff > 100) {
setKeyboardHeight(diff);
} else {
setKeyboardHeight(0);
}
};
visualViewport.addEventListener("resize", handleResize);
// Initial check in case keyboard is already open
handleResize();
return () => visualViewport.removeEventListener("resize", handleResize);
}, [enabled]);
return {
keyboardHeight,
isKeyboardVisible: keyboardHeight > 0,
};
}
registry/default/drawer/hooks/use-visual-viewport-height.ts
import * as React from "react";
export interface UseVisualViewportHeightOptions {
/** Whether tracking is enabled */
enabled?: boolean;
}
/**
* Hook to track the visual viewport height using the Visual Viewport API.
*
* On mobile devices, the visual viewport height changes when the URL bar
* collapses/expands. This hook provides real-time tracking of the actual
* visible viewport height, which can be used to size elements that need
* to fill the visible area regardless of URL bar state.
*
* @returns The current visual viewport height in pixels, or null if not available
*/
export function useVisualViewportHeight({
enabled = true,
}: UseVisualViewportHeightOptions = {}): number | null {
const [height, setHeight] = React.useState<number | null>(() => {
if (typeof window === "undefined") return null;
return window.visualViewport?.height ?? null;
});
React.useEffect(() => {
if (!enabled || typeof window === "undefined") return;
const visualViewport = window.visualViewport;
if (!visualViewport) return;
const handleResize = () => {
setHeight(visualViewport.height);
};
// Set initial value
handleResize();
visualViewport.addEventListener("resize", handleResize);
return () => visualViewport.removeEventListener("resize", handleResize);
}, [enabled]);
return height;
}
registry/default/drawer/drawer.css
/* Scroll-Driven Animations (Chrome 115+, Safari 26+, Firefox flag-only) */
/**
* Consumers can animate based on snap progress:
* .mini-player { opacity: calc(1 - var(--drawer-snap-progress, 0)); }
*/
@property --drawer-snap-progress {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@keyframes drawer-snap-progress {
from {
--drawer-snap-progress: 0;
}
to {
--drawer-snap-progress: 1;
}
}
@keyframes drawer-backdrop-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Overrides scroll-driven animation on exit (transitions can't interpolate from animation values) */
@keyframes drawer-backdrop-exit {
to {
opacity: 0;
}
}
/* Horizontal drawers: prevent page scroll interference */
html:has([data-slot="drawer-viewport"][data-direction="left"]),
html:has([data-slot="drawer-viewport"][data-direction="right"]) {
overscroll-behavior: none;
scrollbar-width: none;
}
html:has(
[data-slot="drawer-viewport"][data-direction="left"]
)::-webkit-scrollbar,
html:has(
[data-slot="drawer-viewport"][data-direction="right"]
)::-webkit-scrollbar {
display: none !important;
}