{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spring-drawer",
  "title": "Spring Drawer",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A drawer with spring physics: drag the handle to snap between heights or flick it closed, or slide in from the left or right, on Radix Dialog for focus trapping and scroll lock.",
  "dependencies": [
    "motion@^12",
    "lucide-react",
    "radix-ui"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/spring-drawer.tsx",
      "content": "// Ballmac UI: Spring Drawer. https://ui.ballmac.com/components/spring-drawer\n\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, animate, motion, useDragControls, useMotionValue, useReducedMotion, useTransform } from \"motion/react\"\nimport { Dialog as DialogPrimitive } from \"radix-ui\"\nimport { X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype DrawerSide = \"bottom\" | \"left\" | \"right\"\n\ntype DrawerContextValue = {\n  open: boolean\n  side: DrawerSide\n  snapPoints: number[]\n  snap: number\n  setSnap: (index: number) => void\n}\n\nconst DrawerContext = React.createContext<DrawerContextValue | null>(null)\n\nfunction useDrawer() {\n  const ctx = React.useContext(DrawerContext)\n  if (!ctx) throw new Error(\"Spring drawer parts must be used inside <SpringDrawer>.\")\n  return ctx\n}\n\ntype SpringDrawerProps = {\n  children: React.ReactNode\n  /** Controlled open state. */\n  open?: boolean\n  /** Initial open state when uncontrolled. */\n  defaultOpen?: boolean\n  /** Called when the drawer opens or closes. */\n  onOpenChange?: (open: boolean) => void\n  /** Edge the drawer comes from. Only the bottom drawer has snap points. */\n  side?: DrawerSide\n  /** Heights the bottom drawer can rest at, as fractions of the screen from 0 to 1, smallest first. */\n  snapPoints?: number[]\n  /** Index of the snap point the drawer opens at. */\n  defaultSnap?: number\n  /** Controlled snap index. */\n  snap?: number\n  /** Called when the drawer settles at another snap point. */\n  onSnapChange?: (index: number) => void\n}\n\nfunction SpringDrawer({ children, open: openProp, defaultOpen = false, onOpenChange, side = \"bottom\", snapPoints = [1], defaultSnap = 0, snap: snapProp, onSnapChange }: SpringDrawerProps) {\n  const [openState, setOpenState] = React.useState(defaultOpen)\n  const open = openProp ?? openState\n  const [snapState, setSnapState] = React.useState(Math.min(defaultSnap, snapPoints.length - 1))\n  const snap = Math.min(snapProp ?? snapState, snapPoints.length - 1)\n  const sorted = React.useMemo(() => [...snapPoints].sort((a, b) => a - b), [snapPoints])\n\n  const change = React.useCallback(\n    (next: boolean) => {\n      setOpenState(next)\n      onOpenChange?.(next)\n      if (next) setSnapState(Math.min(defaultSnap, sorted.length - 1))\n    },\n    [onOpenChange, defaultSnap, sorted.length]\n  )\n  const setSnap = React.useCallback(\n    (index: number) => {\n      const i = Math.min(Math.max(index, 0), sorted.length - 1)\n      setSnapState(i)\n      onSnapChange?.(i)\n    },\n    [onSnapChange, sorted.length]\n  )\n\n  const value = React.useMemo(() => ({ open, side, snapPoints: sorted, snap, setSnap }), [open, side, sorted, snap, setSnap])\n  return (\n    <DrawerContext.Provider value={value}>\n      <DialogPrimitive.Root open={open} onOpenChange={change}>\n        {children}\n      </DialogPrimitive.Root>\n    </DrawerContext.Provider>\n  )\n}\n\nfunction SpringDrawerTrigger({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {\n  return <DialogPrimitive.Trigger data-slot=\"spring-drawer-trigger\" className={className} {...props} />\n}\n\nfunction SpringDrawerClose({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {\n  return <DialogPrimitive.Close data-slot=\"spring-drawer-close\" className={className} {...props} />\n}\n\nfunction SpringDrawerTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {\n  return <DialogPrimitive.Title data-slot=\"spring-drawer-title\" className={cn(\"text-base leading-6 font-semibold text-foreground\", className)} {...props} />\n}\n\nfunction SpringDrawerDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {\n  return <DialogPrimitive.Description data-slot=\"spring-drawer-description\" className={cn(\"text-sm text-muted-foreground\", className)} {...props} />\n}\n\nfunction SpringDrawerHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return <div data-slot=\"spring-drawer-header\" className={cn(\"grid gap-1 px-5 pt-2 pb-3\", className)} {...props} />\n}\n\nfunction SpringDrawerBody({ className, ...props }: React.ComponentProps<\"div\">) {\n  return <div data-slot=\"spring-drawer-body\" className={cn(\"min-h-0 flex-1 overflow-y-auto px-5 pb-5 text-sm\", className)} tabIndex={0} {...props} />\n}\n\nfunction SpringDrawerFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return <div data-slot=\"spring-drawer-footer\" className={cn(\"flex flex-wrap items-center justify-end gap-2 border-t px-5 py-3\", className)} {...props} />\n}\n\ntype SpringDrawerContentProps = Omit<React.ComponentProps<\"div\">, \"ref\" | \"title\"> & {\n  /** Show the close button in the corner. */\n  showCloseButton?: boolean\n  /** Accessible name of the close button. */\n  closeLabel?: string\n  /** Name of the resize handle (bottom drawer with several snap points). */\n  handleLabel?: string\n  /** Largest width of a bottom drawer, or the width of a side drawer. */\n  maxWidth?: string\n}\n\nfunction SpringDrawerContent({ className, children, showCloseButton = true, closeLabel, handleLabel, maxWidth, style, ...props }: SpringDrawerContentProps) {\n  const msg = useMessages()\n  closeLabel ??= msg(\"spring-drawer.closeLabel\", \"Close\")\n  handleLabel ??= msg(\"spring-drawer.handleLabel\", \"Resize drawer\")\n  const { open, side, snapPoints, snap, setSnap } = useDrawer()\n  const reduce = useReducedMotion()\n  const bottom = side === \"bottom\"\n  const axis = bottom ? \"y\" : \"x\"\n  const maxSnap = snapPoints[snapPoints.length - 1] ?? 1\n  const panel = React.useRef<HTMLDivElement>(null)\n  const controls = useDragControls()\n  const offset = useMotionValue(0)\n  const [size, setSize] = React.useState(0)\n  const targetFor = React.useCallback((index: number) => (bottom && size ? size * (1 - (snapPoints[index] ?? maxSnap) / maxSnap) : 0), [bottom, size, snapPoints, maxSnap])\n  const overlay = useTransform(offset, [0, size || 1], [1, 0])\n  const settle = reduce ? { duration: 0 } : ({ type: \"spring\", stiffness: 380, damping: 36, mass: 0.9 } as const)\n  const opened = React.useRef(false)\n\n  // Measure once the panel is mounted, and again if the window changes size.\n  React.useLayoutEffect(() => {\n    const el = panel.current\n    if (!el) return\n    const measure = () => setSize(bottom ? el.offsetHeight : el.offsetWidth)\n    measure()\n    const ro = new ResizeObserver(measure)\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [open, bottom])\n\n  React.useEffect(() => {\n    if (!open) {\n      opened.current = false\n      return\n    }\n    if (!size) return\n    if (!opened.current) {\n      offset.set(size)\n      opened.current = true\n    }\n    const controlsAnim = animate(offset, targetFor(snap), settle)\n    return () => controlsAnim.stop()\n  }, [open, size, snap, targetFor, offset]) // eslint-disable-line react-hooks/exhaustive-deps\n\n  function onDragEnd(_: unknown, info: { velocity: { x: number; y: number } }) {\n    const current = offset.get()\n    const velocity = bottom ? info.velocity.y : info.velocity.x\n    const projected = current + velocity * 0.18\n    const stops = bottom ? snapPoints.map((_, i) => targetFor(i)) : [0]\n    const closed = size\n    let best = closed\n    let bestIndex = -1\n    let distance = Math.abs(projected - closed)\n    stops.forEach((t, i) => {\n      const d = Math.abs(projected - t)\n      if (d < distance) {\n        distance = d\n        best = t\n        bestIndex = i\n      }\n    })\n    if (best === closed) {\n      closeRef.current?.click()\n    } else {\n      if (bottom && bestIndex !== snap) setSnap(bestIndex)\n      animate(offset, best, settle)\n    }\n  }\n\n  const closeRef = React.useRef<HTMLButtonElement>(null)\n  const adjustable = bottom && snapPoints.length > 1\n\n  return (\n    <DialogPrimitive.Portal forceMount>\n      <AnimatePresence>\n        {open && (\n          <>\n            {React.createElement(\n              DialogPrimitive.Overlay,\n              { forceMount: true, asChild: true },\n              <motion.div\n                data-slot=\"spring-drawer-overlay\"\n                className=\"fixed inset-0 z-50 bg-black/50\"\n                style={{ opacity: reduce ? 1 : overlay }}\n                initial={reduce ? { opacity: 0 } : undefined}\n                animate={reduce ? { opacity: 1 } : undefined}\n                exit={{ opacity: 0, transition: { duration: 0.2 } }}\n              />\n            )}\n            {React.createElement(\n              DialogPrimitive.Content,\n              { forceMount: true, asChild: true, \"aria-describedby\": undefined },\n              <motion.div\n                ref={panel}\n                data-slot=\"spring-drawer-content\"\n                data-side={side}\n                drag={size ? axis : false}\n                dragControls={controls}\n                dragListener={false}\n                dragConstraints={bottom ? { top: targetFor(snapPoints.length - 1), bottom: size } : side === \"right\" ? { left: 0, right: size } : { left: -size, right: 0 }}\n                dragElastic={0.12}\n                dragMomentum={false}\n                onDragEnd={onDragEnd}\n                style={{ [axis]: offset, ...(bottom ? { height: `${maxSnap * 100}dvh`, maxWidth: maxWidth ?? \"42rem\" } : { width: maxWidth ?? \"min(24rem, 90vw)\" }), ...style } as React.CSSProperties}\n                exit={{\n                  ...(bottom ? { y: \"100%\" } : side === \"right\" ? { x: \"100%\" } : { x: \"-100%\" }),\n                  transition: reduce ? { duration: 0 } : { type: \"spring\", stiffness: 380, damping: 40 },\n                }}\n                className={cn(\n                  \"fixed z-50 flex flex-col bg-card text-card-foreground shadow-[0_-12px_48px_-12px_rgb(0_0_0/0.35)] outline-none\",\n                  bottom && \"inset-x-0 bottom-0 mx-auto w-full rounded-t-2xl border border-b-0\",\n                  side === \"right\" && \"inset-y-0 right-0 border-l\", // rtl-fixed: fixed right edge\n                  side === \"left\" && \"inset-y-0 left-0 border-r\", // rtl-fixed: fixed left edge\n                  className\n                )}\n                {...(props as object)}\n              >\n                {bottom && (\n                  <div\n                    onPointerDown={(e) => controls.start(e)}\n                    className=\"flex shrink-0 cursor-grab touch-none items-center justify-center py-2.5 active:cursor-grabbing\"\n                  >\n                    <span\n                      role={adjustable ? \"slider\" : undefined}\n                      aria-label={adjustable ? handleLabel : undefined}\n                      aria-orientation={adjustable ? \"vertical\" : undefined}\n                      aria-valuemin={adjustable ? 0 : undefined}\n                      aria-valuemax={adjustable ? snapPoints.length - 1 : undefined}\n                      aria-valuenow={adjustable ? snap : undefined}\n                      aria-valuetext={adjustable ? `${Math.round((snapPoints[snap] ?? 1) * 100)}% of the screen` : undefined}\n                      aria-hidden={adjustable ? undefined : true}\n                      tabIndex={adjustable ? 0 : undefined}\n                      onKeyDown={(e) => {\n                        if (!adjustable) return\n                        const keys: Record<string, number> = { ArrowUp: snap + 1, ArrowRight: snap + 1, ArrowDown: snap - 1, ArrowLeft: snap - 1, Home: 0, End: snapPoints.length - 1 }\n                        if (e.key in keys) {\n                          e.preventDefault()\n                          setSnap(keys[e.key]!)\n                        }\n                      }}\n                      className=\"block h-1.5 w-12 rounded-full bg-border outline-none transition-colors hover:bg-muted-foreground/50 focus-visible:bg-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none\"\n                    />\n                  </div>\n                )}\n                {!bottom && (\n                  <div\n                    onPointerDown={(e) => controls.start(e)}\n                    aria-hidden=\"true\"\n                    className={cn(\"absolute top-1/2 z-10 flex h-16 w-3 -translate-y-1/2 cursor-grab touch-none items-center justify-center active:cursor-grabbing\", side === \"right\" ? \"left-0.5\" : \"right-0.5\")} // rtl-fixed: grab handle sits on the drawer's fixed inner edge\n                  >\n                    <span className=\"block h-10 w-1 rounded-full bg-border\" />\n                  </div>\n                )}\n                {children}\n                {showCloseButton ? (\n                  <DialogPrimitive.Close\n                    ref={closeRef}\n                    aria-label={closeLabel}\n                    className=\"absolute top-3 end-3 inline-flex size-8 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n                  >\n                    <X aria-hidden=\"true\" className=\"size-4\" />\n                  </DialogPrimitive.Close>\n                ) : (\n                  <DialogPrimitive.Close ref={closeRef} aria-hidden=\"true\" tabIndex={-1} className=\"sr-only\" />\n                )}\n              </motion.div>\n            )}\n          </>\n        )}\n      </AnimatePresence>\n    </DialogPrimitive.Portal>\n  )\n}\n\nexport {\n  SpringDrawer,\n  SpringDrawerTrigger,\n  SpringDrawerContent,\n  SpringDrawerHeader,\n  SpringDrawerBody,\n  SpringDrawerFooter,\n  SpringDrawerTitle,\n  SpringDrawerDescription,\n  SpringDrawerClose,\n  type SpringDrawerProps,\n  type SpringDrawerContentProps,\n  type DrawerSide,\n}\n",
      "type": "registry:ui",
      "target": "@components/ballmac/spring-drawer.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "drawer",
      "sheet",
      "snap",
      "drag",
      "dialog"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "<SpringDrawer side snapPoints defaultSnap><SpringDrawerTrigger/><SpringDrawerContent><SpringDrawerHeader><SpringDrawerTitle/></SpringDrawerHeader><SpringDrawerBody/></SpringDrawerContent></SpringDrawer>. snapPoints are fractions of the screen height. The handle is a keyboard slider.",
      "whenToUse": [
        "Mobile-style sheets that can sit half open",
        "Details panels that can be pulled larger"
      ],
      "whenNotToUse": [
        "Simple modals (dialog)",
        "Vaul-style nested drawers (drawer)"
      ],
      "composesWith": [
        "drawer",
        "sheet",
        "dialog"
      ],
      "a11y": [
        {
          "keys": "Escape",
          "action": "Closes the drawer"
        },
        {
          "keys": "ArrowUp / ArrowDown",
          "action": "On the handle, moves between snap points"
        },
        {
          "keys": "Focus",
          "action": "Trapped in the drawer and returned to the trigger on close"
        },
        {
          "keys": "Reduced motion",
          "action": "The drawer moves without springs"
        }
      ],
      "customization": [
        "side",
        "snapPoints and defaultSnap",
        "onSnapChange",
        "maxWidth",
        "showCloseButton"
      ]
    },
    "examples": [
      "spring-drawer-demo",
      "spring-drawer-side"
    ],
    "url": "https://ui.ballmac.com/components/spring-drawer"
  },
  "categories": [
    "primitives"
  ],
  "type": "registry:ui"
}