{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bottom-sheet",
  "type": "registry:ui",
  "title": "Bottom Sheet",
  "description": "A draggable modal sheet that snaps between heights with velocity, built on a native dialog.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/bottom-sheet.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  animate,\n  motion,\n  useDragControls,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type PanInfo,\n} from \"motion/react\";\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface BottomSheetProps {\n  /** Open state (controlled). */\n  open?: boolean;\n  /** Initial open state (uncontrolled). */\n  defaultOpen?: boolean;\n  /** Called when the sheet asks to open or close (drag, Escape, backdrop, close button). */\n  onOpenChange?: (open: boolean) => void;\n  /** Sheet title. Labels the dialog. */\n  title: React.ReactNode;\n  /** Optional supporting text under the title. Describes the dialog. */\n  description?: React.ReactNode;\n  /** Snap heights as fractions of the viewport height, e.g. [0.4, 0.9]. */\n  snapPoints?: number[];\n  /** Index of the snap point the sheet opens to. */\n  defaultSnap?: number;\n  /** Allow closing by dragging down and by clicking the backdrop. Escape and the close button always work. */\n  dismissible?: boolean;\n  /** Backdrop opacity once the sheet reaches its lowest snap point (0–1). */\n  backdropOpacity?: number;\n  /** Maximum sheet width in px (the sheet is centred on wide screens). */\n  maxWidth?: number;\n  /** Spring used for snapping, opening and closing. */\n  spring?: Spring;\n  /** Accent for the active snap marker and focus rings. */\n  accent?: string;\n  /** Accessible name prefix of the drag handle button. */\n  handleLabel?: string;\n  /** Accessible name of the close button. */\n  closeLabel?: string;\n  /** Classes added to the sheet panel. */\n  className?: string;\n  /** Inline styles merged onto the sheet panel. */\n  style?: React.CSSProperties;\n  children?: React.ReactNode;\n}\n\nconst DEFAULT_SNAPS = [0.45, 0.9];\nconst clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));\n\n/**\n * A draggable bottom sheet built on a native modal <dialog> (top layer, inert page, focus return).\n * It snaps between fractional heights using drag velocity, fades its backdrop with progress,\n * locks body scroll and only scrolls its content once it reaches the top snap point.\n */\nexport function BottomSheet({\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  title,\n  description,\n  snapPoints = DEFAULT_SNAPS,\n  defaultSnap = 0,\n  dismissible = true,\n  backdropOpacity = 0.5,\n  maxWidth = 640,\n  spring = { stiffness: 420, damping: 42, mass: 1 },\n  accent = \"#FF7448\",\n  handleLabel = \"Resize sheet\",\n  closeLabel = \"Close\",\n  className = \"\",\n  style,\n  children,\n}: BottomSheetProps) {\n  const uid = React.useId();\n  const titleId = `${uid}-title`;\n  const descriptionId = `${uid}-description`;\n  const reduceMotion = useReducedMotion();\n  const dialogRef = React.useRef<HTMLDialogElement>(null);\n  const returnFocusRef = React.useRef<HTMLElement | null>(null);\n  const releaseVelocityRef = React.useRef(0);\n  const draggedRef = React.useRef(false);\n  const dragControls = useDragControls();\n\n  // Normalised snap points: unique, ascending, within (0, 1].\n  const snaps = Array.from(new Set(snapPoints.map((s) => clamp(s, 0.1, 1)))).sort((a, b) => a - b);\n  if (!snaps.length) snaps.push(0.5);\n  const top = snaps[snaps.length - 1];\n  const initialSnap = clamp(Math.round(defaultSnap), 0, snaps.length - 1);\n\n  const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n  const isOpen = open ?? internalOpen;\n  const [snapIndex, setSnapIndex] = React.useState(initialSnap);\n  const activeSnap = Math.min(snapIndex, snaps.length - 1);\n  const atTop = activeSnap === snaps.length - 1;\n\n  // Each time the sheet opens it starts from the default snap point.\n  const [wasOpen, setWasOpen] = React.useState(isOpen);\n  if (wasOpen !== isOpen) {\n    setWasOpen(isOpen);\n    if (isOpen) setSnapIndex(initialSnap);\n  }\n\n  const setOpen = (next: boolean) => {\n    if (open === undefined) setInternalOpen(next);\n    onOpenChange?.(next);\n  };\n\n  /* ------------------------------ motion values ------------------------------ */\n\n  // y = how far the sheet is pushed down from its top snap position, in px.\n  const y = useMotionValue(0);\n  const viewport = useMotionValue(0);\n  // 0 when fully closed, 1 at (or above) the lowest snap point.\n  const progress = useTransform([y, viewport], ([yv, h]: number[]) => {\n    const closedY = top * h;\n    const lowestY = (top - snaps[0]) * h;\n    const range = closedY - lowestY;\n    return range > 0 ? clamp((closedY - yv) / range, 0, 1) : 1;\n  });\n  const backdrop = useTransform(progress, (p) => p * backdropOpacity);\n\n  const transition = (velocity = 0) =>\n    reduceMotion ? { duration: 0 } : { type: \"spring\" as const, ...spring, velocity };\n  const snapY = (index: number, h: number) => (top - snaps[index]) * h;\n\n  const goTo = (index: number, velocity = 0) => {\n    setSnapIndex(index);\n    animate(y, snapY(index, viewport.get()), transition(velocity));\n  };\n\n  /* --------------------------------- effects --------------------------------- */\n\n  const animateOpen = React.useEffectEvent((dialog: HTMLDialogElement) => {\n    const h = dialog.clientHeight || window.innerHeight;\n    viewport.set(h);\n    y.set(top * h); // start fully below the fold\n    return animate(y, snapY(activeSnap, h), transition());\n  });\n\n  const animateToSnap = React.useEffectEvent(() => animate(y, snapY(activeSnap, viewport.get()), transition()));\n\n  const animateClose = React.useEffectEvent(() => {\n    const velocity = releaseVelocityRef.current;\n    releaseVelocityRef.current = 0;\n    return animate(y, top * viewport.get(), transition(velocity));\n  });\n\n  // Drive the native dialog from state: showModal → slide in; close → slide out, then close().\n  React.useEffect(() => {\n    const dialog = dialogRef.current;\n    if (!dialog) return;\n    if (isOpen) {\n      // Re-opened while the exit animation was still running: just slide back up.\n      if (dialog.open) {\n        const controls = animateToSnap();\n        return () => controls.stop();\n      }\n      returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n      dialog.showModal();\n      const controls = animateOpen(dialog);\n      return () => controls.stop();\n    }\n    if (dialog.open) {\n      let cancelled = false;\n      const controls = animateClose();\n      controls.then(() => {\n        if (cancelled) return;\n        dialog.close();\n        returnFocusRef.current?.focus({ preventScroll: true });\n        returnFocusRef.current = null;\n      });\n      return () => {\n        cancelled = true;\n        controls.stop();\n      };\n    }\n  }, [isOpen]);\n\n  // Keep snap positions in px correct when the viewport resizes.\n  const handleResize = React.useEffectEvent(() => {\n    const dialog = dialogRef.current;\n    if (!dialog?.open) return;\n    const h = dialog.clientHeight || window.innerHeight;\n    viewport.set(h);\n    if (isOpen) y.set(snapY(activeSnap, h));\n  });\n\n  React.useEffect(() => {\n    if (!isOpen) return;\n    const onResize = () => handleResize();\n    window.addEventListener(\"resize\", onResize);\n    return () => window.removeEventListener(\"resize\", onResize);\n  }, [isOpen]);\n\n  // Body scroll lock while open, compensating for the scrollbar so the page does not shift.\n  React.useEffect(() => {\n    if (!isOpen) return;\n    const html = document.documentElement;\n    const body = document.body;\n    const scrollbar = window.innerWidth - html.clientWidth;\n    const prev = { overflow: html.style.overflow, paddingRight: body.style.paddingRight };\n    html.style.overflow = \"hidden\";\n    if (scrollbar > 0) body.style.paddingRight = `${scrollbar}px`;\n    return () => {\n      html.style.overflow = prev.overflow;\n      body.style.paddingRight = prev.paddingRight;\n    };\n  }, [isOpen]);\n\n  /* ---------------------------------- drag ---------------------------------- */\n\n  const startDrag = (event: React.PointerEvent) => {\n    if (event.button !== 0) return;\n    draggedRef.current = false;\n    dragControls.start(event);\n  };\n\n  const handleDragEnd = (_: PointerEvent, info: PanInfo) => {\n    const h = viewport.get();\n    const current = y.get();\n    // Project where a flick would carry the sheet, then pick the closest resting place.\n    const projected = current + info.velocity.y * 0.2;\n    const lowestY = snapY(0, h);\n    const closedY = top * h;\n    if (dismissible && (projected > lowestY + (closedY - lowestY) * 0.5 || (info.velocity.y > 1100 && current > lowestY - 24))) {\n      releaseVelocityRef.current = info.velocity.y;\n      setOpen(false);\n      return;\n    }\n    let best = 0;\n    snaps.forEach((_, i) => {\n      if (Math.abs(snapY(i, h) - projected) < Math.abs(snapY(best, h) - projected)) best = i;\n    });\n    goTo(best, info.velocity.y);\n  };\n\n  /* -------------------------------- keyboard -------------------------------- */\n\n  const handleHandleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {\n    if (event.key === \"ArrowUp\" && activeSnap < snaps.length - 1) {\n      event.preventDefault();\n      goTo(activeSnap + 1);\n    } else if (event.key === \"ArrowDown\") {\n      event.preventDefault();\n      if (activeSnap > 0) goTo(activeSnap - 1);\n      else if (dismissible) setOpen(false);\n    }\n  };\n\n  const percent = Math.round(snaps[activeSnap] * 100);\n\n  return (\n    <dialog\n      ref={dialogRef}\n      aria-labelledby={titleId}\n      aria-describedby={description ? descriptionId : undefined}\n      onCancel={(event) => {\n        // Escape: keep React state the source of truth so the exit animation can run.\n        event.preventDefault();\n        setOpen(false);\n      }}\n      onClose={() => {\n        // Closed by something other than our state (e.g. a form with method=\"dialog\").\n        if (isOpen) setOpen(false);\n      }}\n      className=\"m-0 h-full max-h-none w-full max-w-none overflow-hidden border-0 bg-transparent p-0 backdrop:bg-transparent\"\n      style={{ [\"--bs-accent\" as string]: accent }}\n    >\n      <motion.div\n        aria-hidden=\"true\"\n        className=\"absolute inset-0 bg-zinc-950\"\n        style={{ opacity: backdrop }}\n        onClick={() => {\n          if (dismissible) setOpen(false);\n        }}\n      />\n      <motion.div\n        drag=\"y\"\n        dragControls={dragControls}\n        dragListener={false}\n        dragMomentum={false}\n        dragConstraints={{ top: 0 }}\n        dragElastic={{ top: 0.06, bottom: 1 }}\n        onDragStart={() => {\n          draggedRef.current = true;\n        }}\n        onDragEnd={handleDragEnd}\n        className={`absolute inset-x-0 bottom-0 mx-auto flex w-full flex-col rounded-t-[22px] border border-b-0 border-zinc-900/10 bg-white text-zinc-900 shadow-[0_-20px_60px_-30px_rgba(0,0,0,0.35)] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 dark:shadow-[0_-20px_60px_-30px_rgba(0,0,0,0.9)] ${className}`}\n        style={{ y, height: `${top * 100}%`, maxWidth, ...style }}\n      >\n        {/* Covers the gap below the sheet when a spring or drag overshoots upward. */}\n        <div aria-hidden=\"true\" className=\"absolute inset-x-[-1px] top-full h-24 border-x border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-900\" />\n\n        <div className=\"shrink-0 cursor-grab touch-none select-none px-5 pb-3 active:cursor-grabbing\" onPointerDown={startDrag}>\n          <div className=\"flex justify-center pt-2\">\n            <button\n              type=\"button\"\n              aria-label={`${handleLabel}: ${percent}% height`}\n              aria-keyshortcuts=\"ArrowUp ArrowDown\"\n              onClick={() => {\n                // A drag that ends on the handle also fires click; ignore it.\n                if (draggedRef.current) {\n                  draggedRef.current = false;\n                  return;\n                }\n                goTo((activeSnap + 1) % snaps.length);\n              }}\n              onKeyDown={handleHandleKeyDown}\n              className=\"group flex h-6 w-16 items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--bs-accent)]\"\n            >\n              <span className=\"h-1 w-10 rounded-full bg-zinc-900/15 transition-colors group-hover:bg-zinc-900/30 dark:bg-white/20 dark:group-hover:bg-white/35\" />\n            </button>\n          </div>\n          <div className=\"mt-2 flex items-start gap-4\">\n            <div className=\"min-w-0 flex-1\">\n              <h2 id={titleId} className=\"text-base font-semibold tracking-tight text-zinc-950 dark:text-zinc-50\">\n                {title}\n              </h2>\n              {description && (\n                <p id={descriptionId} className=\"mt-0.5 text-sm text-zinc-500 dark:text-zinc-400\">\n                  {description}\n                </p>\n              )}\n            </div>\n            {snaps.length > 1 && (\n              <div aria-hidden=\"true\" className=\"flex items-center gap-1.5 pt-1.5\">\n                {snaps.map((s, i) => (\n                  <span\n                    key={s}\n                    className={`h-1.5 rounded-full transition-[width,background-color] duration-300 ${\n                      i === activeSnap ? \"w-4 bg-[var(--bs-accent)]\" : \"w-1.5 bg-zinc-900/15 dark:bg-white/20\"\n                    }`}\n                  />\n                ))}\n                <span className=\"ml-1 font-mono text-[10px] uppercase tracking-[0.14em] tabular-nums text-zinc-500 dark:text-zinc-400\">\n                  {percent}%\n                </span>\n              </div>\n            )}\n            <button\n              type=\"button\"\n              aria-label={closeLabel}\n              onClick={() => setOpen(false)}\n              className=\"-mr-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-zinc-500 transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--bs-accent)] dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-white\"\n            >\n              <svg viewBox=\"0 0 16 16\" className=\"h-4 w-4\" aria-hidden=\"true\">\n                <path d=\"m4 4 8 8M12 4l-8 8\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n              </svg>\n            </button>\n          </div>\n        </div>\n\n        {/*\n          Below the top snap the content is a drag surface (no scrolling); at the top snap it\n          scrolls on its own and the header remains the drag handle.\n        */}\n        <div\n          onPointerDown={atTop ? undefined : startDrag}\n          className={`min-h-0 flex-1 overscroll-contain border-t border-zinc-900/[0.08] px-5 pb-8 pt-4 dark:border-white/[0.08] ${\n            atTop ? \"touch-pan-y overflow-y-auto\" : \"touch-none overflow-hidden\"\n          }`}\n        >\n          {children}\n        </div>\n      </motion.div>\n    </dialog>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/bottom-sheet"
}
