{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"drawer","type":"registry:ui","title":"Drawer","description":"A modal bottom sheet with a grab handle, draggable shut with a downward swipe — for filters, quick actions, or any touch-first task","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["overlays"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/drawer/Drawer.tsx","content":"\"use client\";\n\nimport { forwardRef, useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { flushSync } from \"react-dom\";\n\nimport { cn } from \"../../utils\";\nimport { Portal } from \"../../internals/Portal\";\nimport { useDismissable } from \"../../internals/dismissable\";\nimport { useFocusTrap } from \"../../internals/focus-trap\";\nimport { useScrollLock } from \"../../internals/scroll-lock\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { useIsomorphicLayoutEffect } from \"../../internals/dom/ssr\";\nimport { useFancyId } from \"../../internals/use-id\";\nimport { anchored, prefersReducedMotion } from \"../../internals/motion/anchored\";\nimport { DURATIONS, JS_EASINGS } from \"../../internals/motion/tokens\";\nimport { usePresence } from \"../../internals/motion/presence\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport type { TransitionSpec } from \"../../internals/motion/transitions\";\nimport \"./drawer.css\";\n\nexport interface DrawerProps {\n\t/** Whether the drawer is open. Controlled when given; the component keeps its own copy either way. */\n\topen?: boolean;\n\t/** Called with the new value whenever the drawer opens or closes. */\n\tonOpenChange?: (open: boolean) => void;\n\t/** Heading rendered in the header and wired to `aria-labelledby`. */\n\ttitle?: string;\n\t/** Supporting text under the title, wired to `aria-describedby`. */\n\tdescription?: string;\n\t/** Whether Escape, the scrim and the close button can close the drawer. */\n\tdismissible?: boolean;\n\t/** Whether dragging the handle down past the threshold closes the drawer. */\n\tswipeToClose?: boolean;\n\t/** Panel body content. */\n\tchildren?: ReactNode;\n\t/** Content pinned below the body, e.g. actions. */\n\tfooter?: ReactNode;\n\t/**\n\t * The panel's `id`. A trigger the caller renders outside the component\n\t * points its `aria-controls` at it while the panel is open.\n\t */\n\tid?: string;\n\t/** Additional CSS classes merged onto the panel. */\n\tclassName?: string;\n\t/**\n\t * Accessible name for the panel when there is no `title`. A modal dialog\n\t * must be named, and `aria-labelledby` can only point at a rendered\n\t * heading — pass this when the header is built from `children` instead.\n\t */\n\tariaLabel?: string;\n\t/**\n\t * Plays the `close` cue through the sound controller when the drawer is\n\t * dismissed, including a committed swipe. Off by default; only audible\n\t * once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\n// Fixed pixel distance rather than a percentage of the panel's own\n// height: the panel's rendered height depends on its content, so a\n// percentage threshold would make the same physical drag distance close\n// the drawer sometimes and not others. A flat distance also keeps the\n// gesture's tests deterministic without needing real layout from jsdom.\nconst DISMISS_THRESHOLD_PX = 96;\n// Matches the transition-duration on `.ft-drawer-panel--releasing` in drawer.css.\nconst SPRING_BACK_MS = 200;\n\nexport const Drawer = forwardRef<HTMLDivElement, DrawerProps>(function Drawer(\n\t{\n\t\topen: openProp,\n\t\tonOpenChange,\n\t\ttitle,\n\t\tdescription,\n\t\tdismissible = true,\n\t\tswipeToClose = true,\n\t\tchildren,\n\t\tfooter,\n\t\tid,\n\t\tclassName,\n\t\tariaLabel,\n\t\tsound = false,\n\t},\n\tforwardedRef\n) {\n\t// The React shape of the source's `open = $bindable(false)`: an internal\n\t// copy seeded from the prop, re-synced during render whenever the CALLER\n\t// changes the prop, and free to move on its own in between. Re-synced in\n\t// the render path, not an effect: an effect would paint one frame of the\n\t// stale value first.\n\tconst [open, setOpenState] = useState(openProp ?? false);\n\tconst [lastOpenProp, setLastOpenProp] = useState(openProp);\n\tif (lastOpenProp !== openProp) {\n\t\tsetLastOpenProp(openProp);\n\t\tsetOpenState(openProp ?? false);\n\t}\n\n\t// A no-op while `sound` is false, so the call site below stays unguarded.\n\tconst playCue = useSoundCue(sound);\n\n\tfunction close() {\n\t\t// The same guard keeps the cue honest: every dismissal — Escape, the\n\t\t// scrim, the close button, a committed swipe — funnels through here,\n\t\t// while a parent writing `open` to false never does, and a second\n\t\t// Escape landing during the exit is already gone by the time it\n\t\t// arrives.\n\t\tif (!open) return;\n\t\tsetOpenState(false);\n\t\tplayCue(\"close\");\n\t\tonOpenChange?.(false);\n\t}\n\n\t// Swiping is a second way to trigger the same `dismissible` decision the\n\t// close button, scrim and Escape already respect — a drawer marked\n\t// non-dismissible can't be swiped away either, only closed\n\t// programmatically by the caller.\n\tconst canSwipe = dismissible && swipeToClose;\n\n\t// `dragY` is a live pixel offset applied as an inline transform while a\n\t// pointer drag is in progress; it is not layout (no height/top changes),\n\t// so dragging never triggers reflow of the page behind the drawer.\n\t//\n\t// A ref plus a direct style write, not state: the value moves once per\n\t// pointermove and drives nothing but this one inline transform, so a\n\t// re-render per frame would buy nothing (the source's `style:transform`\n\t// is that same per-frame write, routed through its compiler).\n\tconst dragYRef = useRef(0);\n\tconst draggingRef = useRef(false);\n\t// True for the short window after a released drag springs back to 0 —\n\t// the only time a transition is applied to `transform`, so live dragging\n\t// itself always tracks the pointer with zero lag. State, not a ref: it\n\t// toggles a class on the rendered panel.\n\tconst [releasing, setReleasing] = useState(false);\n\tconst activePointerIdRef = useRef<number | null>(null);\n\tconst dragStartYRef = useRef(0);\n\tconst springBackTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\n\t// One seed per instance, suffixed for title/description — SSR-stable.\n\tconst uid = useFancyId();\n\tconst titleId = title ? `${uid}-title` : undefined;\n\tconst descriptionId = description ? `${uid}-description` : undefined;\n\n\t// Convention C-1: the NODE, not a ref. The panel is created by\n\t// `presence.mounted`, so a `useRef` + `[]`-deps effect would still be\n\t// holding `null` when the trap's effect fires and the trap would never\n\t// arm — silently.\n\tconst [panel, setPanelNode] = useElementRef<HTMLDivElement>();\n\n\t// Returns the two functions the source hands out through `onActivate`:\n\t// the eager return, and the re-arm.\n\tconst trap = useFocusTrap(panel);\n\n\t// The one place this component owns motion, and the other half of the\n\t// swipe gesture. Not `anchored`: that helper is scale+opacity by design\n\t// and carries no translate term, and a drawer's whole gesture is travel.\n\t// Not a pixel distance either — the panel's height depends on its\n\t// content, and only `%` clears its own edge whatever that height is.\n\t//\n\t// The exit does not halve its travel the way the scale rung does: a\n\t// drawer that slid half-way down and then vanished reads worse than one\n\t// that simply leaves. A named exception, not an oversight.\n\t//\n\t// `from` is where the panel is at the instant the exit starts, read\n\t// straight off the live `dragYRef` rather than from a value captured on\n\t// the way into `close()`. A parent writing `open` to false — mid-drag\n\t// included — never goes through `close()` at all, and a captured offset\n\t// would then still be 0 while the panel's inline transform sat at the\n\t// finger's position: the drawer would snap back to rest and only then\n\t// slide out. `dragYRef` is already correct on every path: 0 for a scrim\n\t// click, Escape and the close button, and left exactly where the finger\n\t// let go for a past-threshold release. At `t = 1` the panel sits exactly\n\t// there; at `t = 0` it is a full height below the viewport. One\n\t// continuous motion rather than a snap back followed by a slide. On the\n\t// way in `from` is always 0: an entrance starts off-screen and ends at\n\t// rest, and a stale offset from an earlier swipe must not become the\n\t// resting position.\n\tfunction drawerSlide(_node: Element, params?: { entering: boolean }): TransitionSpec {\n\t\tconst reduced = prefersReducedMotion();\n\t\tconst entering = params?.entering ?? false;\n\t\tconst from = entering ? 0 : dragYRef.current;\n\t\treturn {\n\t\t\tdelay: 0,\n\t\t\t// Reduced motion collapses this to 0, which makes `runTransition`\n\t\t\t// finish synchronously and never touch `element.animate()` — so\n\t\t\t// the close is exactly as synchronous as it was before the drawer\n\t\t\t// animated out at all.\n\t\t\tduration: reduced ? 0 : entering ? DURATIONS.base : DURATIONS.exit,\n\t\t\teasing: entering ? JS_EASINGS.out : JS_EASINGS.in,\n\t\t\tcss: (t, u) => `transform: translateY(calc(${from}px * ${t} + 100% * ${u}))`,\n\t\t};\n\t}\n\n\tconst presence = usePresence(open, {\n\t\t// The two halves of the focus handshake, at the two moments the\n\t\t// source puts them: `onintrostart` → rearm, `onoutrostart` →\n\t\t// returnFocusNow.\n\t\t//\n\t\t// `returnFocusNow` at the dismiss instant is the whole point: it is\n\t\t// called on EVERY close path (Escape, the scrim, the close button, a\n\t\t// past-threshold swipe, a caller's own `open` write). Waiting for the\n\t\t// trap's own destroy would leave a keyboard user on `<body>` for the\n\t\t// whole length of the slide-out, because the closing panel is made\n\t\t// inert the instant the exit starts.\n\t\t//\n\t\t// `rearm` is the other half. A drawer reopened DURING its exit\n\t\t// reverses instead of remounting, so the trap is never re-created:\n\t\t// without this the panel would come back `aria-modal` and interactive\n\t\t// with focus left on the trigger behind it, and the eager return\n\t\t// already spent for the life of the instance.\n\t\tonEnterStart: () => trap.rearm(),\n\t\tonExitStart: () => trap.returnFocusNow(),\n\t});\n\n\t// The drag offset is deliberately NOT zeroed on a past-threshold release\n\t// (see `handlePointerUp`) — it is the exit's start point. The reset\n\t// therefore happens on the way back IN, before paint, so a drawer swiped\n\t// shut does not reopen already pushed down by the last swipe's distance.\n\tuseIsomorphicLayoutEffect(() => {\n\t\tif (!open) return;\n\t\tdragYRef.current = 0;\n\t\tif (panel) panel.style.transform = \"translateY(0px)\";\n\t}, [open, panel]);\n\n\t// A drag released below the threshold arms the spring-back timer; if the\n\t// drawer unmounts before it fires — closed by its own trigger, or the\n\t// whole overlay removed some other way — nothing would otherwise clear it.\n\tuseEffect(() => {\n\t\treturn () => {\n\t\t\tclearTimeout(springBackTimerRef.current);\n\t\t};\n\t}, []);\n\n\t// Release timing: keyed on `presence.mounted`, not `open`, so the page\n\t// stays locked until the panel has actually finished sliding out instead\n\t// of unlocking the instant `open` flips and leaving the page scrollable\n\t// under a scrim still on screen — the same reason the source reached for\n\t// `use:scrollLock` (outro-delayed destroy) over an `$effect`.\n\tuseScrollLock(presence.mounted);\n\n\t// `active: open` — a plain boolean where the source needed `() => open`.\n\t// The layer stays ON the stack for its whole exit and stops being TOP of\n\t// it the instant `open` flips, so a second Escape during the slide-out\n\t// changes nothing.\n\tuseDismissable(panel, {\n\t\tonDismiss: close,\n\t\tescape: dismissible,\n\t\toutsideClick: dismissible,\n\t\tactive: open,\n\t\tkeepFocus: true,\n\t});\n\n\t// Convention C-2: composed ABOVE the conditional below.\n\tconst panelRef = useComposedRefs(\n\t\tsetPanelNode,\n\t\tforwardedRef,\n\t\tpresence.register(\"panel\", drawerSlide, (entering) => ({ entering }))\n\t);\n\n\t// The scrim fades on opacity alone (`scale: false`) while the panel\n\t// travels, and both run the same clock, so they leave together and the\n\t// unmount is a tie rather than a straggler.\n\tconst scrimRef = presence.register(\"scrim\", anchored, (entering) => ({\n\t\tentering,\n\t\tscale: false,\n\t\tduration: DURATIONS.base,\n\t\texitDuration: DURATIONS.exit,\n\t}));\n\n\tfunction handlePointerDown(event: React.PointerEvent<HTMLDivElement>) {\n\t\tif (!canSwipe) return;\n\t\tif (event.pointerType === \"mouse\" && event.button !== 0) return;\n\t\tclearTimeout(springBackTimerRef.current);\n\t\tactivePointerIdRef.current = event.pointerId;\n\t\tdragStartYRef.current = event.clientY;\n\t\tdraggingRef.current = true;\n\t\tsetReleasing(false);\n\t\t// Keeps receiving pointermove/pointerup on this element even if the\n\t\t// pointer strays outside it mid-drag (a fast, slightly diagonal\n\t\t// swipe easily leaves a few-pixel-tall handle row).\n\t\t(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId);\n\t}\n\n\tfunction handlePointerMove(event: React.PointerEvent<HTMLDivElement>) {\n\t\tif (!draggingRef.current || event.pointerId !== activePointerIdRef.current) return;\n\t\t// Only downward drag moves the panel; upward movement clamps at 0\n\t\t// rather than lifting the drawer past its resting position.\n\t\tdragYRef.current = Math.max(0, event.clientY - dragStartYRef.current);\n\t\tif (panel) panel.style.transform = `translateY(${dragYRef.current}px)`;\n\t}\n\n\tfunction releaseCapture(event: React.PointerEvent<HTMLDivElement>) {\n\t\t(event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId);\n\t}\n\n\t// Below the threshold: spring back rather than sticking wherever the\n\t// pointer let go. `releasing` must be IN the DOM before the transform\n\t// write for the CSS transition to pick the change up — the source's\n\t// reactive writes flush together; here `flushSync` is what puts the class\n\t// and the transform in the same frame.\n\tfunction springBack() {\n\t\tflushSync(() => {\n\t\t\tsetReleasing(true);\n\t\t});\n\t\tdragYRef.current = 0;\n\t\tif (panel) panel.style.transform = \"translateY(0px)\";\n\t\tspringBackTimerRef.current = setTimeout(() => {\n\t\t\tsetReleasing(false);\n\t\t}, SPRING_BACK_MS);\n\t}\n\n\tfunction handlePointerUp(event: React.PointerEvent<HTMLDivElement>) {\n\t\tif (!draggingRef.current || event.pointerId !== activePointerIdRef.current) return;\n\t\tdraggingRef.current = false;\n\t\tactivePointerIdRef.current = null;\n\t\treleaseCapture(event);\n\n\t\tif (dragYRef.current > DISMISS_THRESHOLD_PX) {\n\t\t\t// `dragYRef` is NOT zeroed here. Zeroing it used to be harmless\n\t\t\t// because removal was instant; with an exit it would snap the\n\t\t\t// panel back up to rest and then slide it down — two gestures\n\t\t\t// where the user made one. `close()` captures the offset as the\n\t\t\t// exit's start point instead, so the slide-out carries on from\n\t\t\t// exactly where the finger let go.\n\t\t\tclose();\n\t\t\treturn;\n\t\t}\n\n\t\tspringBack();\n\t}\n\n\tfunction handlePointerCancel(event: React.PointerEvent<HTMLDivElement>) {\n\t\tif (!draggingRef.current || event.pointerId !== activePointerIdRef.current) return;\n\t\tdraggingRef.current = false;\n\t\tactivePointerIdRef.current = null;\n\t\treleaseCapture(event);\n\t\tspringBack();\n\t}\n\n\tconst panelClasses = cn(\n\t\t\"ft-drawer-panel bg-popover text-popover-foreground fixed inset-x-0 bottom-0 z-50 flex max-h-[85vh] flex-col gap-3 rounded-t-[14px] border-t border-r border-l border-border pt-3.5 pr-5 pb-5 pl-5 shadow-2xl\",\n\t\treleasing && \"ft-drawer-panel--releasing\",\n\t\tclassName\n\t);\n\n\t// No portal-before-focus-trap ordering ceremony here (divergence D-10):\n\t// `createPortal` commits children into the container before any effect\n\t// runs and refs populate before layout effects, so the node the trap\n\t// focuses is always connected.\n\t//\n\t// The `Portal` stays mounted and its CHILDREN are what `presence.mounted`\n\t// gates — `usePortalTarget` resolves its container in a layout effect, so\n\t// a `Portal` mounting in the same commit as the surface would skip the\n\t// entrance outright (the same hoist `DialogSurface` records).\n\t//\n\t// `data-state` is an ordinary attribute (divergence D-2) carrying\n\t// `surfaceState`'s TWO values (convention C-5); `inert` is not written by\n\t// hand, because `usePresence` sets it on every registered node for the\n\t// whole exit. The inline transform still carries the live drag offset,\n\t// and the two never fight: a running animation wins over an inline style,\n\t// so the transition owns `transform` for the whole exit and hands back to\n\t// the inline value only once it is finished — by which point the panel is\n\t// gone.\n\treturn (\n\t\t<Portal>\n\t\t\t{presence.mounted ? (\n\t\t\t\t<>\n\t\t\t\t\t<div\n\t\t\t\t\t\tref={scrimRef}\n\t\t\t\t\t\tclassName=\"ft-drawer-scrim fixed inset-0 z-50 bg-black/60\"\n\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t/>\n\t\t\t\t\t<div\n\t\t\t\t\t\tref={panelRef}\n\t\t\t\t\t\tid={id}\n\t\t\t\t\t\tclassName={panelClasses}\n\t\t\t\t\t\trole=\"dialog\"\n\t\t\t\t\t\taria-modal=\"true\"\n\t\t\t\t\t\taria-label={titleId ? undefined : ariaLabel}\n\t\t\t\t\t\taria-labelledby={titleId}\n\t\t\t\t\t\taria-describedby={descriptionId}\n\t\t\t\t\t\tdata-state={presence.surfaceState}\n\t\t\t\t\t\tstyle={{ transform: \"translateY(0px)\" }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"ft-drawer-drag-surface flex flex-col items-center gap-2 pb-1\"\n\t\t\t\t\t\t\tonPointerDown={handlePointerDown}\n\t\t\t\t\t\t\tonPointerMove={handlePointerMove}\n\t\t\t\t\t\t\tonPointerUp={handlePointerUp}\n\t\t\t\t\t\t\tonPointerCancel={handlePointerCancel}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span className=\"ft-drawer-handle\" aria-hidden=\"true\"></span>\n\t\t\t\t\t\t\t{title ? (\n\t\t\t\t\t\t\t\t<h2 id={titleId} className=\"w-full text-[14px] font-semibold\">\n\t\t\t\t\t\t\t\t\t{title}\n\t\t\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t{description ? (\n\t\t\t\t\t\t\t\t<p\n\t\t\t\t\t\t\t\t\tid={descriptionId}\n\t\t\t\t\t\t\t\t\tclassName=\"text-muted-foreground w-full text-[12px] leading-relaxed\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{description}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{dismissible ? (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tclassName=\"ft-drawer-close text-muted-foreground hover:text-foreground absolute top-3.5 right-5 cursor-pointer text-[13px] leading-none\"\n\t\t\t\t\t\t\t\taria-label=\"Close\"\n\t\t\t\t\t\t\t\tonClick={close}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t✕\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t<div className=\"ft-drawer-body flex flex-1 flex-col gap-3 overflow-y-auto\">\n\t\t\t\t\t\t\t{children}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{footer ? (\n\t\t\t\t\t\t\t<div className=\"ft-drawer-footer flex justify-end gap-2\">{footer}</div>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t) : null}\n\t\t</Portal>\n\t);\n});\n\nDrawer.displayName = \"Drawer\";\n","type":"registry:ui","target":"@ui/fancy/components/drawer/Drawer.tsx"},{"path":"react/src/components/drawer/drawer.css","content":"/*\n * The `<style>` block of the source Drawer, moved to a colocated stylesheet\n * (PORTING.md §Styling rule 2). The compiler scoped that block; plain CSS is\n * global, so every rule below is anchored on `.ft-drawer-panel` — the class\n * the panel already carries in its own `cn()` call, so no port-added anchor\n * was needed here.\n *\n * The entrance and its resting rule used to live here as keyframes; both\n * are now the JS transition in Drawer.tsx, which emits no transform at rest\n * and collapses to zero duration under reduced motion. What survives is the\n * spring-back, which is a different interaction on a different curve.\n */\n@media (prefers-reduced-motion: no-preference) {\n\t/*\n\t * The only place a CSS transition ever touches `transform`: a released\n\t * drag that fell short of the dismiss threshold. Live dragging itself\n\t * never carries this class, so the panel tracks the pointer with no lag;\n\t * only the snap-back afterwards eases. A drag released PAST the\n\t * threshold never carries it either — that one is handed to the exit\n\t * transition instead, from wherever the finger let go.\n\t */\n\t.ft-drawer-panel.ft-drawer-panel--releasing {\n\t\ttransition: transform 0.2s cubic-bezier(0.32, 0.72, 0, 1);\n\t}\n}\n\n.ft-drawer-panel .ft-drawer-drag-surface {\n\t/*\n\t * Without this, a touch drag starting on the handle is first interpreted\n\t * by the browser as a page-scroll/pan gesture: it both withholds the\n\t * continuous pointermove stream our handler needs and fights our\n\t * transform with the browser's own scroll offset. Pointer capture alone\n\t * does not stop that tug-of-war — only touch-action does.\n\t */\n\ttouch-action: none;\n\tcursor: grab;\n}\n\n.ft-drawer-panel .ft-drawer-handle {\n\twidth: 40px;\n\theight: 4px;\n\tborder-radius: 2px;\n\tbackground-color: var(--color-border, rgba(255, 255, 255, 0.2));\n}\n","type":"registry:ui","target":"@ui/fancy/components/drawer/drawer.css"},{"path":"react/src/components/drawer/index.ts","content":"\"use client\";\n\nexport { Drawer } from \"./Drawer\";\nexport type { DrawerProps } from \"./Drawer\";\n","type":"registry:ui","target":"@ui/fancy/components/drawer/index.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/drawer?framework=react"}}