{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"motion-dialog","type":"registry:component","title":"Dialog (Motion)","description":"Dialog with motion: it grows out of the button that opened it, shrinks back into it as it closes, glides to a new height when its content changes, and swipes down to close on a phone.","dependencies":["lucide-react","motion","radix-ui"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/lib-portal.json","https://dashral.com/r/motion-reduced.json","https://dashral.com/r/motion-tokens.json","https://dashral.com/r/dialog.json","https://dashral.com/r/scroll-shadow.json"],"files":[{"path":"registry/dashral/motion/dialog.tsx","type":"registry:file","target":"components/dashral/motion/dialog.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { Dialog as DialogPrimitive } from \"radix-ui\"\nimport { XIcon } from \"lucide-react\"\nimport { AnimatePresence, animate, motion, useDragControls, useMotionValue, useTransform } from \"motion/react\"\nimport type { DialogProps as KitDialogProps, DialogSize } from \"../dialog\"\nimport { useLocale } from \"../lib/locale\"\nimport { cn } from \"../lib/utils\"\nimport { usePortalContainer } from \"../lib/portal\"\nimport { ScrollShadow } from \"../scroll-shadow\"\n\nimport { useReducedMotion } from \"../lib/motion-reduced\"\nimport { duration, ease, spring } from \"../lib/motion-tokens\"\n\nexport type { DialogSize } from \"../dialog\"\n\nexport type DialogProps = KitDialogProps & {\n  /** On a touch screen, swipe the panel down to close it, as a sheet does. On by default. */\n  swipeToClose?: boolean\n  /**\n   * How it grows out of its trigger. \"soft\" (the default): from a little smaller, quickly. \"trigger\": from the\n   * trigger's own size and place, travelling to the middle on a smooth, slower curve (no bounce) and shrinking back into it as it closes,\n   * its content coming in once it's grown: for a dialog that should read as coming out of the button.\n   */\n  grow?: \"soft\" | \"trigger\"\n}\n\n/** A light buzz on phones that have one. */\nconst buzz = (pattern: number | number[]) => {\n  if (typeof navigator !== \"undefined\" && \"vibrate\" in navigator) navigator.vibrate(pattern)\n}\n\n/** Where a swipe shouldn't start: a field you type in, or something you drag or scroll on your own. */\nconst keepsTouch = \"input, textarea, select, [contenteditable], [data-slot=slider], [role=slider]\"\nexport { DialogClose } from \"../dialog\"\n\nconst sizes: Record<DialogSize, string> = {\n  sm: \"sm:max-w-sm\",\n  md: \"sm:max-w-lg\",\n  lg: \"sm:max-w-2xl\",\n  xl: \"sm:max-w-4xl\",\n}\n\n/**\n * The kit's Dialog with motion: the same trigger, title, body, footer, form and props. It grows out of the button that\n * opened it on a quick spring and shrinks back into it as it closes; the page behind dims and blurs as it comes. When what's\n * inside changes (a form's next step, an error appearing), the panel glides to its new height instead of jumping.\n * Opened without a trigger, it grows from the middle. On a touch screen, swipe it down and it follows your finger,\n * the backdrop clearing as it goes: far or fast enough and it slides away closed (phones buzz), short of it it\n * springs back; upward it barely gives. With reduced motion it only fades and doesn't swipe.\n */\nexport function Dialog({\n  trigger,\n  title,\n  description,\n  children,\n  footer,\n  form,\n  size = \"md\",\n  showCloseButton = true,\n  swipeToClose = true,\n  grow = \"soft\",\n  className,\n  classNames,\n  open,\n  defaultOpen,\n  onOpenChange,\n  ...props\n}: DialogProps) {\n  const portalContainer = usePortalContainer()\n  const { t } = useLocale()\n  const reduced = useReducedMotion()\n  // Kept here, so the panel can stay on screen while it closes.\n  const [own, setOwn] = React.useState(defaultOpen ?? false)\n  const isOpen = open ?? own\n  const change = (next: boolean) => {\n    if (next) setSwiped(false)\n    if (open === undefined) setOwn(next)\n    onOpenChange?.(next)\n  }\n\n  // A swipe down on a touch screen: how far the panel has come, the backdrop clearing with it, and whether a swipe\n  // closed it (so it leaves downward instead of shrinking back into its button).\n  const dragY = useMotionValue(0)\n  const backdrop = useTransform(dragY, [0, 320], [1, 0.15])\n  const swipe = useDragControls()\n  const [swiped, setSwiped] = React.useState(false)\n  React.useEffect(() => {\n    if (isOpen) dragY.jump(0)\n  }, [isOpen, dragY])\n  const canSwipe = swipeToClose && !reduced\n\n  // Where it grows from: the trigger's middle, turned into a point inside the panel. The panel sits in the middle of\n  // the viewport (left and top 50%, pulled back by half its size), so its untransformed size places it.\n  const opener = React.useRef<HTMLButtonElement>(null)\n  const [origin, setOrigin] = React.useState(\"50% 50%\")\n  // Measured as the panel attaches, not in an effect when `open` turns true: Radix's portal mounts the panel a render\n  // later, so an effect would find no panel yet and leave it growing from the middle. A ref callback runs before the\n  // first frame is drawn, so the panel starts its growth at the trigger.\n  // grow=\"trigger\": the panel's scale starts at the trigger's size (the larger of its two ratios) and its content waits.\n  const fromTrigger = grow === \"trigger\" && !reduced\n  const growScale = useMotionValue(1)\n  const contentOpacity = useMotionValue(1)\n  const startScale = React.useRef(0.6)\n  const panel = React.useCallback(\n    (node: HTMLDivElement | null) => {\n      if (!node) return\n      const box = opener.current?.getBoundingClientRect()\n      if (!box || !box.width) {\n        setOrigin(\"50% 50%\")\n        startScale.current = 0.6\n      } else {\n        const left = (window.innerWidth - node.offsetWidth) / 2\n        const top = (window.innerHeight - node.offsetHeight) / 2\n        setOrigin(`${box.left + box.width / 2 - left}px ${box.top + box.height / 2 - top}px`)\n        startScale.current = Math.min(0.6, Math.max(0.05, box.width / node.offsetWidth, box.height / node.offsetHeight))\n      }\n      if (!fromTrigger) return\n      growScale.jump(startScale.current)\n      contentOpacity.jump(0)\n      // A smooth curve, no spring: it leaves the trigger gently, travels, and eases into the middle without a bounce.\n      animate(growScale, 1, { duration: 0.45, ease: [0.4, 0, 0.2, 1] })\n      animate(contentOpacity, 1, { duration: duration.standard, ease: ease.enter, delay: 0.16 })\n    },\n    [fromTrigger, growScale, contentOpacity]\n  )\n  // Closing back into the trigger: the content goes first, so only the shape travels back, as it came.\n  React.useEffect(() => {\n    if (!isOpen && fromTrigger) animate(contentOpacity, 0, { duration: 0.1, ease: ease.exit })\n  }, [isOpen, fromTrigger, contentOpacity])\n\n  // The body's height follows what's inside on a glide; before it's measured (and with reduced motion) it's natural.\n  const inside = React.useRef<HTMLDivElement>(null)\n  const height = useMotionValue<number | \"auto\">(\"auto\")\n  const [gliding, setGliding] = React.useState(false)\n  React.useEffect(() => {\n    const node = inside.current\n    if (!isOpen || !node) return\n    let first = true\n    const observer = new ResizeObserver(([entry]) => {\n      const next = entry?.borderBoxSize[0]?.blockSize ?? node.offsetHeight\n      if (first || reduced) height.jump(next)\n      else {\n        setGliding(true)\n        animate(height, next, { ...spring.glide, onComplete: () => setGliding(false) })\n      }\n      first = false\n    })\n    observer.observe(node)\n    return () => {\n      observer.disconnect()\n      height.jump(\"auto\")\n    }\n  }, [isOpen, height, reduced])\n\n  const inner = (\n    <>\n      <div data-slot=\"dialog-header\" className={cn(\"flex flex-col gap-2 text-center sm:text-start\", classNames?.header)}>\n        <DialogPrimitive.Title data-slot=\"dialog-title\" className={cn(\"font-heading text-lg leading-none font-medium tracking-[-0.02em]\", classNames?.title)}>\n          {title}\n        </DialogPrimitive.Title>\n        {description ? (\n          <DialogPrimitive.Description data-slot=\"dialog-description\" className={cn(\"text-sm text-muted-foreground\", classNames?.description)}>\n            {description}\n          </DialogPrimitive.Description>\n        ) : null}\n      </div>\n      {/* Only the middle scrolls when the dialog is taller than the screen: the title and the buttons stay in view. */}\n      <ScrollShadow\n        data-slot=\"dialog-body\"\n        size={24}\n        className={cn(\"-mx-6 -my-1 grid min-h-0 flex-1 gap-4 overscroll-contain px-6 py-1\", classNames?.body)}\n      >\n        {children}\n      </ScrollShadow>\n      {footer && (\n        <div data-slot=\"dialog-footer\" className={cn(\n            // Side by side, each taking its share, on a phone too; the main one wraps above only when the labels can't share a line.\n            \"flex flex-wrap-reverse gap-2 sm:flex-wrap sm:justify-end [&>*]:min-w-fit [&>*]:flex-1 sm:[&>*]:flex-none\",\n            classNames?.footer\n          )}>\n          {footer}\n        </div>\n      )}\n    </>\n  )\n\n  return (\n    <DialogPrimitive.Root data-slot=\"dialog\" open={isOpen} onOpenChange={change} {...props}>\n      {trigger && (\n        <DialogPrimitive.Trigger asChild ref={opener}>\n          {trigger}\n        </DialogPrimitive.Trigger>\n      )}\n      <AnimatePresence>\n        {isOpen && (\n          <DialogPrimitive.Portal forceMount container={portalContainer}>\n            <DialogPrimitive.Overlay forceMount asChild>\n              <motion.div\n                data-slot=\"dialog-overlay\"\n                className=\"fixed inset-0 z-50\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0, transition: { duration: duration.fast, ease: ease.exit } }}\n                transition={{ duration: duration.standard, ease: ease.enter }}\n              >\n                {/* The blur stays put and only the opacity moves (animating a full-screen blur costs frames); a swipe clears it. */}\n                <motion.div className=\"absolute inset-0 bg-black/40 backdrop-blur-[2px]\" style={{ opacity: backdrop }} />\n              </motion.div>\n            </DialogPrimitive.Overlay>\n            <DialogPrimitive.Content\n              forceMount\n              asChild\n              // Without a description, say so explicitly: otherwise Radix warns that one is missing.\n              {...(description ? {} : { \"aria-describedby\": undefined })}\n            >\n              <motion.div\n                ref={panel}\n                data-slot=\"dialog-content\"\n                style={fromTrigger ? { transformOrigin: origin, y: dragY, scale: growScale } : { transformOrigin: origin, y: dragY }}\n                drag={canSwipe ? \"y\" : false}\n                dragControls={swipe}\n                dragListener={false}\n                dragConstraints={{ top: 0, bottom: 0 }}\n                // Down it follows the finger; up it barely gives.\n                dragElastic={{ top: 0.06, bottom: 1 }}\n                onPointerDown={(event) => {\n                  if (!canSwipe || event.pointerType !== \"touch\") return\n                  if ((event.target as HTMLElement).closest(keepsTouch)) return\n                  // A body that scrolls (taller than the screen) keeps the finger for scrolling; the title and the\n                  // buttons still swipe the panel away.\n                  const body = (event.target as HTMLElement).closest<HTMLElement>(\"[data-slot=dialog-body]\")\n                  if (body && body.scrollHeight > body.clientHeight + 1) return\n                  swipe.start(event)\n                }}\n                onDragEnd={(_, info) => {\n                  if (info.offset.y > 110 || info.velocity.y > 600) {\n                    buzz(8)\n                    setSwiped(true)\n                    change(false)\n                  } else animate(dragY, 0, spring.move)\n                }}\n                initial={reduced ? { opacity: 0 } : fromTrigger ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                animate={fromTrigger ? { opacity: 1 } : { opacity: 1, scale: 1 }}\n                exit={\n                  reduced\n                    ? { opacity: 0, transition: { duration: duration.fast } }\n                    : swiped\n                      ? { y: \"100vh\", transition: { duration: duration.standard, ease: ease.exit } }\n                      : fromTrigger\n                        ? // Back into the trigger: the shape shrinks to it, fading only at the end.\n                          { scale: startScale.current, opacity: 0, transition: { scale: { duration: 0.3, ease: [0.4, 0, 0.2, 1] }, opacity: { duration: duration.fast, delay: 0.14 } } }\n                        : { opacity: 0, scale: 0.8, transition: { duration: duration.fast, ease: ease.exit } }\n                }\n                transition={\n                  reduced\n                    ? { duration: duration.fast }\n                    : fromTrigger\n                      ? { opacity: { duration: 0.12, ease: ease.enter } }\n                      : { ...spring.appear, opacity: { duration: duration.fast, ease: ease.enter } }\n                }\n                className={cn(\n                  // Centred with translate, which motion leaves alone: it scales with transform.\n                  \"fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col rounded-lg border bg-background p-6 shadow-lg\",\n                  sizes[size],\n                  className\n                )}\n              >\n                {canSwipe && (\n                  // A handle on touch screens, saying the panel can be swiped down.\n                  <span aria-hidden className=\"absolute top-2 left-1/2 hidden h-1 w-9 -translate-x-1/2 rounded-full bg-muted-foreground/25 pointer-coarse:block\" />\n                )}\n                {/* Clipped only while it glides, so a focus ring at the edge isn't cut off the rest of the time. */}\n                <motion.div style={{ height, opacity: contentOpacity }} className={cn(\"flex min-h-0 flex-col\", gliding && \"overflow-hidden\")}>\n                  {/* At most the screen's height less the panel's padding, so a tall body scrolls inside it. */}\n                  <div ref={inside} className=\"flex max-h-[calc(100dvh-5rem)] min-h-0 flex-col gap-4\">\n                    {form ? (\n                      <form {...form} className={cn(\"flex min-h-0 flex-1 flex-col gap-4\", form.className)}>\n                        {inner}\n                      </form>\n                    ) : (\n                      inner\n                    )}\n                  </div>\n                </motion.div>\n                {showCloseButton && (\n                  <DialogPrimitive.Close className=\"absolute top-4 end-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:size-4\">\n                    <XIcon />\n                    <span className=\"sr-only\">{t(\"close\")}</span>\n                  </DialogPrimitive.Close>\n                )}\n              </motion.div>\n            </DialogPrimitive.Content>\n          </DialogPrimitive.Portal>\n        )}\n      </AnimatePresence>\n    </DialogPrimitive.Root>\n  )\n}\n"}],"cssVars":{"theme":{"font-heading":"var(--font-geist, \"Geist\"), var(--font-inter, \"Inter\"), var(--font-arabic, \"IBM Plex Sans Arabic\"), ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif"},"light":{},"dark":{}}}