shadimage
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 · light
class-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;
}

More from cubby-ui

Browse all components in this library →