{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "side-drawer",
  "type": "registry:ui",
  "title": "Side Drawer",
  "description": "A left or right panel on a native dialog: modal overlay or non-modal push, with velocity drag-to-close.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/side-drawer.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useDragControls, useMotionValue, useTransform, type PanInfo } from \"motion/react\";\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface SideDrawerProps {\n  /** Open state (controlled). */\n  open?: boolean;\n  /** Initial open state (uncontrolled). */\n  defaultOpen?: boolean;\n  /** Called when the drawer asks to open or close (drag, Escape, backdrop, close button). */\n  onOpenChange?: (open: boolean) => void;\n  /** Drawer title. Labels the dialog. */\n  title: React.ReactNode;\n  /** Supporting text under the title. Describes the dialog. */\n  description?: React.ReactNode;\n  /** Pinned footer, e.g. actions. The body scrolls between header and footer. */\n  footer?: React.ReactNode;\n  /** Edge the drawer slides in from. */\n  side?: \"left\" | \"right\";\n  /**\n   * \"overlay\": modal (top layer, backdrop, inert page, scroll lock).\n   * \"push\": non-modal panel inside the nearest positioned container that shifts `pushRef` aside.\n   */\n  mode?: \"overlay\" | \"push\";\n  /** In push mode, the element that shifts aside while the drawer is open. */\n  pushRef?: React.RefObject<HTMLElement | null>;\n  /** Panel width in px (never wider than the viewport / container minus 48px). */\n  width?: number;\n  /** Allow closing by dragging the panel away and by clicking the backdrop. Escape and the close button always work. */\n  dismissible?: boolean;\n  /** Backdrop opacity when fully open (overlay mode). */\n  backdropOpacity?: number;\n  /** Spring for opening, closing and settling after a drag. */\n  spring?: Spring;\n  /** Accent for focus rings and the drag grip. */\n  accent?: string;\n  /** Accessible name of the close button. */\n  closeLabel?: string;\n  /** Classes added to the panel. */\n  className?: string;\n  /** Inline styles merged onto the panel. */\n  style?: React.CSSProperties;\n  children?: React.ReactNode;\n}\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));\nconst SHADOW_ROOM = 32; // extra px travelled when closing so the panel shadow leaves the screen too\n\n/**\n * A side panel on a native <dialog>. Overlay mode opens it modally (top layer, inert page, backdrop,\n * body scroll lock); push mode opens it non-modally inside a container and slides `pushRef` aside.\n * Drag the panel towards its edge to dismiss: release velocity is projected to decide. Focus moves in\n * on open and returns on close; with reduced motion the panel fades instead of sliding.\n */\nexport function SideDrawer({\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  title,\n  description,\n  footer,\n  side = \"right\",\n  mode = \"overlay\",\n  pushRef,\n  width = 380,\n  dismissible = true,\n  backdropOpacity = 0.4,\n  spring = { stiffness: 380, damping: 38, mass: 1 },\n  accent = \"#FF7448\",\n  closeLabel = \"Close\",\n  className = \"\",\n  style,\n  children,\n}: SideDrawerProps) {\n  const uid = React.useId();\n  const titleId = `${uid}-title`;\n  const descriptionId = `${uid}-description`;\n  const reduceMotion = usePrefersReducedMotion();\n  const dialogRef = React.useRef<HTMLDialogElement>(null);\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const returnFocusRef = React.useRef<HTMLElement | null>(null);\n  const releaseVelocityRef = React.useRef(0);\n  const mountedRef = React.useRef(false);\n  const dragControls = useDragControls();\n\n  const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n  const isOpen = open ?? internalOpen;\n  const modal = mode === \"overlay\";\n  const direction = side === \"right\" ? 1 : -1; // sign of x that moves the panel off screen\n\n  const setOpen = (next: boolean) => {\n    if (open === undefined) setInternalOpen(next);\n    onOpenChange?.(next);\n  };\n\n  /* ------------------------------ motion values ------------------------------ */\n\n  // x: drag / slide offset from the open position. fade: 0 when closed, 1 when shown (also the\n  // reduced-motion animation). panelWidth: measured, so the maths respects the max-width clamp.\n  const x = useMotionValue(0);\n  const fade = useMotionValue(0);\n  const panelWidth = useMotionValue(width);\n  const progress = useTransform([x, fade, panelWidth], ([xv, f, w]: number[]) =>\n    w > 0 ? f * (1 - clamp(Math.abs(xv) / w, 0, 1)) : f,\n  );\n  const backdrop = useTransform(progress, (p) => p * backdropOpacity);\n\n  // Push mode: slide the provided content by the drawer's visible width (instant under reduced motion).\n  const writePush = React.useEffectEvent((target: HTMLElement, p: number) => {\n    const w = panelWidth.get();\n    const amount = reduceMotion ? (fade.get() > 0.001 ? 1 - clamp(Math.abs(x.get()) / w, 0, 1) : 0) : p;\n    target.style.setProperty(\"translate\", amount > 0.0005 ? `${(-direction * amount * w).toFixed(2)}px 0` : \"\");\n  });\n\n  React.useEffect(() => {\n    const target = pushRef?.current;\n    if (!target || modal) return;\n    const unsubscribe = progress.on(\"change\", (p) => writePush(target, p));\n    return () => {\n      unsubscribe();\n      target.style.removeProperty(\"translate\");\n    };\n  }, [pushRef, modal, progress]);\n\n  const transition = (velocity = 0) => ({ type: \"spring\" as const, ...spring, velocity });\n  const fadeTransition = { duration: 0.18, ease: \"easeOut\" as const };\n  const closedX = () => direction * (panelWidth.get() + SHADOW_ROOM);\n\n  /* --------------------------------- effects --------------------------------- */\n\n  const animateIn = React.useEffectEvent((fromClosed: boolean) => {\n    const panel = panelRef.current;\n    if (panel) panelWidth.set(panel.offsetWidth);\n    if (reduceMotion) {\n      x.set(0);\n      return animate(fade, 1, fadeTransition);\n    }\n    fade.set(1);\n    if (fromClosed) x.set(closedX());\n    return animate(x, 0, transition());\n  });\n\n  const animateOut = React.useEffectEvent(() => {\n    const velocity = releaseVelocityRef.current;\n    releaseVelocityRef.current = 0;\n    if (reduceMotion) return animate(fade, 0, fadeTransition);\n    return animate(x, closedX(), transition(velocity));\n  });\n\n  const focusInside = React.useEffectEvent((dialog: HTMLDialogElement) => {\n    if (dialog.contains(document.activeElement)) return;\n    const target =\n      dialog.querySelector<HTMLElement>(\"[autofocus]\") ??\n      dialog.querySelector<HTMLElement>(\"button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])\");\n    target?.focus({ preventScroll: true });\n  });\n\n  // Drive the native dialog from state: open → show / showModal and slide in; close → slide out, then close().\n  React.useEffect(() => {\n    const dialog = dialogRef.current;\n    const initial = !mountedRef.current;\n    mountedRef.current = true;\n    if (!dialog) return;\n    if (isOpen) {\n      if (dialog.open) {\n        // Re-opened while the exit animation was still running.\n        const controls = animateIn(false);\n        return () => controls.stop();\n      }\n      returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n      if (modal) {\n        dialog.showModal();\n      } else if (initial) {\n        // Open on first render (defaultOpen): show it without taking focus from the page.\n        dialog.setAttribute(\"open\", \"\");\n      } else {\n        dialog.show();\n        focusInside(dialog);\n      }\n      const controls = animateIn(true);\n      return () => controls.stop();\n    }\n    if (dialog.open) {\n      let cancelled = false;\n      const controls = animateOut();\n      controls.then(() => {\n        if (cancelled) return;\n        fade.set(0);\n        dialog.close();\n        const returnTo = returnFocusRef.current;\n        returnFocusRef.current = null;\n        if (returnTo?.isConnected) returnTo.focus({ preventScroll: true });\n      });\n      return () => {\n        cancelled = true;\n        controls.stop();\n      };\n    }\n  }, [isOpen, modal, fade]);\n\n  // Keep the measured width current (window resize, width prop changes).\n  React.useEffect(() => {\n    const panel = panelRef.current;\n    if (!panel) return;\n    const ro = new ResizeObserver(() => {\n      if (panel.offsetWidth > 0) panelWidth.set(panel.offsetWidth);\n    });\n    ro.observe(panel);\n    return () => ro.disconnect();\n  }, [panelWidth]);\n\n  // Overlay mode locks body scroll while open, compensating for the scrollbar.\n  React.useEffect(() => {\n    if (!isOpen || !modal) 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, modal]);\n\n  /* ---------------------------------- drag ---------------------------------- */\n\n  const startDrag = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (!dismissible || event.button !== 0) return;\n    const target = event.target as HTMLElement;\n    if (target.closest(\"button, a, input, select, textarea, label, [role=slider], [contenteditable]\")) return;\n    // Touch and pen drag from anywhere (vertical pans still scroll the body); a mouse drags from the\n    // header or the grip so text in the body stays selectable.\n    if (event.pointerType === \"mouse\" && !target.closest(\"[data-drawer-handle]\")) return;\n    dragControls.start(event);\n  };\n\n  const handleDragEnd = (_: PointerEvent, info: PanInfo) => {\n    const w = panelWidth.get();\n    const offset = x.get() * direction; // > 0 means towards the closed edge\n    const velocity = info.velocity.x * direction;\n    const projected = offset + velocity * 0.2;\n    if (dismissible && (projected > w * 0.5 || (velocity > 900 && offset > 12))) {\n      releaseVelocityRef.current = info.velocity.x;\n      setOpen(false);\n      return;\n    }\n    animate(x, 0, reduceMotion ? { duration: 0 } : transition(info.velocity.x));\n  };\n\n  /* --------------------------------- render ---------------------------------- */\n\n  const focusRing =\n    \"outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sd-accent)]\";\n  const edge = side === \"right\" ? \"right-0\" : \"left-0\";\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 runs.\n        event.preventDefault();\n        setOpen(false);\n      }}\n      onKeyDown={(event) => {\n        // Non-modal dialogs do not receive \"cancel\" on Escape in every browser.\n        if (event.key === \"Escape\" && !modal && !event.defaultPrevented) {\n          event.preventDefault();\n          setOpen(false);\n        }\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={\n        modal\n          ? \"fixed inset-0 m-0 h-full max-h-none w-full max-w-none overflow-clip border-0 bg-transparent p-0 backdrop:bg-transparent\"\n          : // Covers the container without blocking it; only the panel takes pointer events.\n            \"pointer-events-none absolute inset-0 z-20 m-0 h-full max-h-none w-full max-w-none overflow-clip border-0 bg-transparent p-0\"\n      }\n      style={{ [\"--sd-accent\" as string]: accent }}\n    >\n      {modal && (\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      )}\n      <motion.div\n        ref={panelRef}\n        drag={dismissible ? \"x\" : false}\n        dragControls={dragControls}\n        dragListener={false}\n        dragMomentum={false}\n        dragConstraints={side === \"right\" ? { left: 0 } : { right: 0 }}\n        dragElastic={side === \"right\" ? { left: 0.04, right: 1 } : { left: 1, right: 0.04 }}\n        onDragEnd={handleDragEnd}\n        onPointerDown={startDrag}\n        className={`pointer-events-auto absolute inset-y-0 ${edge} flex touch-pan-y flex-col bg-white text-zinc-900 outline-hidden dark:bg-zinc-900 dark:text-zinc-100 ${\n          side === \"right\"\n            ? \"border-l shadow-[-24px_0_60px_-32px_rgba(0,0,0,0.35)] dark:shadow-[-24px_0_60px_-32px_rgba(0,0,0,0.9)]\"\n            : \"border-r shadow-[24px_0_60px_-32px_rgba(0,0,0,0.35)] dark:shadow-[24px_0_60px_-32px_rgba(0,0,0,0.9)]\"\n        } border-zinc-900/10 dark:border-white/10 ${className}`}\n        style={{\n          x,\n          opacity: reduceMotion ? fade : 1,\n          width: `min(${width}px, calc(100${modal ? \"vw\" : \"%\"} - 3rem))`,\n          ...style,\n        }}\n      >\n        {/* Grip on the inner edge: a drag affordance for pointers. */}\n        {dismissible && (\n          <div\n            aria-hidden=\"true\"\n            data-drawer-handle=\"\"\n            className={`group absolute inset-y-0 z-10 flex w-4 cursor-grab items-center justify-center active:cursor-grabbing ${\n              side === \"right\" ? \"-left-2\" : \"-right-2\"\n            }`}\n          >\n            <span className=\"h-10 w-1 rounded-full bg-zinc-900/15 transition-colors group-hover:bg-[var(--sd-accent)] dark:bg-white/20\" />\n          </div>\n        )}\n\n        {/* Opaque strip that fills the gap if a spring overshoots past the open position. */}\n        <div\n          aria-hidden=\"true\"\n          className={`absolute inset-y-0 w-16 bg-white dark:bg-zinc-900 ${side === \"right\" ? \"left-full\" : \"right-full\"}`}\n        />\n\n        <header\n          data-drawer-handle=\"\"\n          className={`flex shrink-0 items-start gap-4 border-b border-zinc-900/[0.08] px-5 pb-4 pt-5 dark:border-white/[0.08] ${dismissible ? \"cursor-grab select-none active:cursor-grabbing\" : \"\"}`}\n        >\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          <button\n            type=\"button\"\n            aria-label={closeLabel}\n            onClick={() => setOpen(false)}\n            className={`-mr-1.5 -mt-1 flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-zinc-500 transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-white ${focusRing}`}\n          >\n            <svg viewBox=\"0 0 16 16\" className=\"size-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        </header>\n\n        <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-4\">{children}</div>\n\n        {footer && (\n          <footer className=\"shrink-0 border-t border-zinc-900/[0.08] bg-zinc-50/80 px-5 py-3.5 dark:border-white/[0.08] dark:bg-white/[0.02]\">\n            {footer}\n          </footer>\n        )}\n      </motion.div>\n    </dialog>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/side-drawer"
}
