{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bottom-sheet",
  "type": "registry:ui",
  "title": "Bottom sheet",
  "description": "A sheet that rests at a peek or full height and follows your finger.",
  "categories": [
    "disclosure"
  ],
  "dependencies": [
    "@radix-ui/react-dialog",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/bottom-sheet/bottom-sheet.tsx",
      "content": "\"use client\";\n\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type {\n  CSSProperties,\n  FocusEvent,\n  KeyboardEvent,\n  PointerEvent as ReactPointerEvent,\n  ReactNode,\n  WheelEvent,\n} from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  usePresence,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport { X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\n/**\n * A sheet that rises from the bottom edge and rests at one or more heights (detents).\n * Use it for secondary tasks where a peek is useful before committing to the full view: trip details, share options, filters.\n * Use a dialog when the decision must interrupt, and a drawer for side panels on wide layouts.\n */\nexport interface BottomSheetProps {\n  /** The control that opens the sheet, usually a button. Focus returns to it when the sheet closes. */\n  trigger?: ReactNode;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  title: string;\n  description?: string;\n  /** Resting heights as fractions of the viewport height. The sheet opens at `initialDetent` and never grows past the largest. */\n  detents?: number[];\n  /** Index into the sorted detents the sheet opens at. Defaults to the smallest. */\n  initialDetent?: number;\n  onDetentChange?: (index: number) => void;\n  closeLabel?: string;\n  className?: string;\n  children: ReactNode;\n}\n\ntype Stop = number | \"closed\";\ntype Drag = { startY: number; origin: number; from: number; moved: boolean; samples: { t: number; y: number }[] };\n\n/** Extra surface below the viewport, so an upward stretch never shows a gap under the sheet. */\nconst EXTENSION = 160;\n/** Enough travel below the edge to hide the floating shadow as well as the sheet. */\nconst CLOSED_GAP = 40;\n/** How far a release coasts, in seconds of its velocity, before the nearest detent is chosen. */\nconst PROJECTION = 0.2;\n/** A release faster than this (px/s) always moves at least one detent in its direction. */\nconst FLICK = 320;\n/** The limit an upward stretch approaches past the tallest detent. */\nconst STRETCH = 120;\n/** How much of the full dim remains at the smallest detent. */\nconst LOW_DIM = 0.78;\n\nconst settle = motionTokens.spring.smooth;\n/** Leaving is shorter than arriving; a flick keeps its velocity through the same spring. */\nconst leave = { ...motionTokens.spring.smooth, visualDuration: 0.3 };\nconst fade = {\n  duration: motionTokens.duration.fast,\n  ease: [...motionTokens.ease.standard] as [number, number, number, number],\n};\n\n/** iOS-style resistance: follows the finger at first, then approaches STRETCH. */\nconst rubber = (distance: number) => (1 - 1 / ((distance * 0.55) / STRETCH + 1)) * STRETCH;\nconst unrubber = (stretch: number) => ((1 / (1 - Math.min(stretch, STRETCH - 1) / STRETCH) - 1) * STRETCH) / 0.55;\nconst clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));\n\nfunction velocityOf(samples: Drag[\"samples\"], now: number) {\n  const recent = samples.filter((sample) => now - sample.t <= 80);\n  const first = recent[0],\n    last = recent[recent.length - 1];\n  // A pause before letting go drops the momentum, so a slow, deliberate release settles where it is.\n  if (!first || !last || last === first || now - last.t > 60) return 0;\n  return (last.y - first.y) / ((last.t - first.t) / 1000);\n}\n\nexport function BottomSheet({\n  trigger,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  ...props\n}: BottomSheetProps) {\n  const [uncontrolled, setUncontrolled] = useState(defaultOpen);\n  const open = openProp ?? uncontrolled;\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (openProp === undefined) setUncontrolled(next);\n      onOpenChange?.(next);\n    },\n    [openProp, onOpenChange],\n  );\n  const dismiss = useCallback(() => setOpen(false), [setOpen]);\n  return (\n    <DialogPrimitive.Root open={open} onOpenChange={setOpen}>\n      {trigger ? <DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger> : null}\n      {/* The sheet stays mounted while it leaves, so a close can be caught and reversed mid-flight. */}\n      <AnimatePresence>\n        {open && (\n          <DialogPrimitive.Portal key=\"sheet\" forceMount>\n            <Sheet {...props} onDismiss={dismiss} />\n          </DialogPrimitive.Portal>\n        )}\n      </AnimatePresence>\n    </DialogPrimitive.Root>\n  );\n}\n\nfunction Sheet({\n  title,\n  description,\n  detents = [0.45, 0.92],\n  initialDetent = 0,\n  onDetentChange,\n  closeLabel = \"Close\",\n  className,\n  children,\n  onDismiss,\n}: Omit<BottomSheetProps, \"trigger\" | \"open\" | \"defaultOpen\" | \"onOpenChange\"> & { onDismiss: () => void }) {\n  const [isPresent, safeToRemove] = usePresence();\n  const reduced = useReducedMotion() ?? false;\n  const detentKey = detents.join(\",\");\n  const stops = useMemo(\n    () =>\n      detentKey\n        .split(\",\")\n        .map(Number)\n        .filter((stop) => stop > 0)\n        .sort((a, b) => a - b),\n    [detentKey],\n  );\n  const top = stops.length - 1;\n  const [detent, setDetent] = useState(() => clamp(Math.round(initialDetent), 0, top));\n  const [announcement, setAnnouncement] = useState(\"\");\n  const sheetRef = useRef<HTMLDivElement>(null);\n  const bodyRef = useRef<HTMLDivElement>(null);\n  /** Offset of the sheet below its tallest detent, in px. Starts far below the fold so the first frame never flashes. */\n  const y = useMotionValue(4000);\n  const height = useMotionValue(0);\n  const presence = useMotionValue(1);\n  const detentRef = useRef(detent);\n  const aim = useRef<Stop>(detent);\n  const drag = useRef<Drag | null>(null);\n  const arrive = useRef<(() => void) | undefined>(undefined);\n  const suppressClick = useRef(false);\n  const mounted = useRef(false);\n  /** When the sheet last started entering or leaving, so a press that began while it was leaving never dismisses it once it is reopened. */\n  const presenceChange = useRef({ present: isPresent, at: 0 });\n  useLayoutEffect(() => {\n    presenceChange.current = { present: isPresent, at: performance.now() };\n  }, [isPresent]);\n\n  const offset = useCallback(\n    (stop: Stop) => {\n      const full = height.get();\n      return stop === \"closed\" ? full + CLOSED_GAP : full * (1 - stops[stop]! / stops[top]!);\n    },\n    [height, stops, top],\n  );\n\n  // The backdrop is a function of where the sheet is, so it darkens and clears with a drag instead of on a timer.\n  const backdrop = useTransform([y, height, presence], ([offsetY = 0, full = 0, shown = 0]: number[]) => {\n    if (!full) return 0;\n    const low = full * (1 - stops[0]! / stops[top]!),\n      closed = full + CLOSED_GAP;\n    const dim =\n      offsetY <= low ? 1 - (1 - LOW_DIM) * (low ? offsetY / low : 0) : LOW_DIM * (1 - (offsetY - low) / (closed - low));\n    return clamp(dim, 0, 1) * shown;\n  });\n\n  // Every move retargets the one motion value from wherever it is. A new target drops the previous arrival callback.\n  const go = useCallback(\n    (stop: Stop, velocity?: number, onArrive?: () => void) => {\n      aim.current = stop;\n      arrive.current = onArrive;\n      const target = offset(stop);\n      const done = () => {\n        const callback = arrive.current;\n        arrive.current = undefined;\n        callback?.();\n      };\n      if (reduced) {\n        y.jump(target);\n        done();\n        return;\n      }\n      animate(y, target, {\n        ...(stop === \"closed\" ? leave : settle),\n        ...(velocity === undefined ? {} : { velocity }),\n        onComplete: done,\n      });\n    },\n    [offset, reduced, y],\n  );\n\n  const rest = useCallback(\n    (stop: number, velocity?: number) => {\n      if (stop !== detentRef.current) {\n        detentRef.current = stop;\n        setDetent(stop);\n        onDetentChange?.(stop);\n        setAnnouncement(\n          stop === top\n            ? \"Sheet expanded\"\n            : stop === 0\n              ? \"Sheet collapsed\"\n              : `Sheet at ${Math.round(stops[stop]! * 100)} percent height`,\n        );\n      }\n      // Collapsing takes the content back to its start, so the peek always shows the top of it.\n      const body = bodyRef.current;\n      if (stop !== top && body && body.scrollTop > 0) body.scrollTo({ top: 0, behavior: reduced ? \"auto\" : \"smooth\" });\n      go(stop, velocity);\n    },\n    [go, onDetentChange, reduced, stops, top],\n  );\n\n  // Enter, leave, and a reopen caught mid-exit all retarget the same motion value from wherever it is.\n  useLayoutEffect(() => {\n    const sheet = sheetRef.current;\n    if (!sheet) return;\n    if (!mounted.current) {\n      mounted.current = true;\n      height.set(sheet.offsetHeight - EXTENSION);\n      y.jump(offset(\"closed\"));\n      if (reduced) {\n        y.jump(offset(detentRef.current));\n        presence.jump(0);\n      }\n    }\n    if (isPresent) {\n      if (reduced) {\n        y.jump(offset(detentRef.current));\n        animate(presence, 1, fade);\n        return;\n      }\n      presence.jump(1);\n      go(detentRef.current);\n      return;\n    }\n    if (reduced) {\n      animate(presence, 0, { ...fade, onComplete: safeToRemove });\n      return;\n    }\n    // A flick has already started the exit with its own velocity; only the unmount waits for it.\n    if (aim.current === \"closed\" && y.isAnimating()) arrive.current = safeToRemove;\n    else go(\"closed\", undefined, safeToRemove);\n  }, [go, height, isPresent, offset, presence, reduced, safeToRemove, y]);\n\n  // A viewport change (rotation, the mobile toolbar) moves every detent. Keep the sheet on its detent, retargeting any flight.\n  useEffect(() => {\n    const sheet = sheetRef.current;\n    if (!sheet || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      const next = sheet.offsetHeight - EXTENSION;\n      if (next === height.get()) return;\n      height.set(next);\n      const stop = aim.current;\n      if (drag.current || stop === \"closed\") return;\n      if (y.isAnimating()) go(stop);\n      else y.jump(offset(stop));\n    });\n    observer.observe(sheet);\n    return () => observer.disconnect();\n  }, [go, height, offset, y]);\n\n  const beginDrag = useCallback(\n    (clientY: number, time: number) => {\n      // Catch the sheet where it is, even mid-flight; an overstretched sheet converts back to finger distance so nothing jumps.\n      y.stop();\n      const current = y.get();\n      drag.current = {\n        startY: clientY,\n        origin: current < 0 ? -unrubber(-current) : current,\n        from: detentRef.current,\n        moved: false,\n        samples: [{ t: time, y: clientY }],\n      };\n    },\n    [y],\n  );\n\n  const moveDrag = useCallback(\n    (clientY: number, time: number) => {\n      const state = drag.current;\n      if (!state) return;\n      const delta = clientY - state.startY;\n      if (!state.moved) {\n        if (Math.abs(delta) < 3) return;\n        state.moved = true;\n        sheetRef.current?.setAttribute(\"data-dragging\", \"\");\n      }\n      const raw = state.origin + delta;\n      // Down follows the finger 1:1 so it can dismiss; up past the tallest detent resists like a rubber band.\n      y.set(raw < 0 ? -rubber(-raw) : raw);\n      state.samples.push({ t: time, y: clientY });\n      if (state.samples.length > 12) state.samples.shift();\n    },\n    [y],\n  );\n\n  const endDrag = useCallback(\n    (time: number) => {\n      const state = drag.current;\n      drag.current = null;\n      sheetRef.current?.removeAttribute(\"data-dragging\");\n      if (!state) return;\n      if (!state.moved) {\n        const stop = aim.current;\n        if (stop !== \"closed\") go(stop);\n        return;\n      }\n      suppressClick.current = true;\n      const current = y.get();\n      // A stretched sheet springs back without the finger's speed, so it never launches past the stretch.\n      const velocity = current < 0 ? 0 : velocityOf(state.samples, time);\n      const projected = current + velocity * PROJECTION;\n      const candidates: Stop[] = [...stops.map((_, index) => index), \"closed\"];\n      let target = candidates.reduce((best, stop) =>\n        Math.abs(offset(stop) - projected) < Math.abs(offset(best) - projected) ? stop : best,\n      );\n      // A quick flick always travels at least one detent, and a flick down from the smallest one closes the sheet.\n      if (Math.abs(velocity) > FLICK && target === state.from)\n        target = velocity < 0 ? Math.min(state.from + 1, top) : state.from === 0 ? \"closed\" : state.from - 1;\n      // The exit starts in this frame with the finger's velocity; closing the dialog only waits for it to land.\n      if (target === \"closed\") {\n        if (!reduced) go(\"closed\", velocity);\n        onDismiss();\n        return;\n      }\n      rest(target, velocity);\n    },\n    [go, offset, onDismiss, reduced, rest, stops, top, y],\n  );\n\n  // Touch drags on the content move the sheet until it is fully open; once open, the content scrolls and a pull down from its top moves the sheet again.\n  useEffect(() => {\n    const body = bodyRef.current;\n    if (!body) return;\n    let gesture: { x: number; y: number; mode: \"pending\" | \"sheet\" | \"native\" } | null = null;\n    const start = (event: TouchEvent) => {\n      const touch = event.touches[0];\n      gesture = event.touches.length === 1 && touch ? { x: touch.clientX, y: touch.clientY, mode: \"pending\" } : null;\n    };\n    const move = (event: TouchEvent) => {\n      const touch = event.touches[0];\n      if (!gesture || !touch || event.touches.length !== 1) return;\n      const dx = touch.clientX - gesture.x,\n        dy = touch.clientY - gesture.y;\n      if (gesture.mode === \"pending\") {\n        const expanded = detentRef.current === top;\n        if (expanded) {\n          const scrollable = body.scrollHeight > body.clientHeight + 1;\n          gesture.mode =\n            Math.abs(dy) >= Math.abs(dx) && (!scrollable || (body.scrollTop <= 0 && dy > 0)) ? \"sheet\" : \"native\";\n        } else {\n          if (Math.hypot(dx, dy) < 4) return;\n          gesture.mode = Math.abs(dy) >= Math.abs(dx) ? \"sheet\" : \"native\";\n        }\n        if (gesture.mode === \"sheet\") beginDrag(touch.clientY, event.timeStamp);\n      }\n      if (gesture.mode !== \"sheet\") return;\n      if (event.cancelable) event.preventDefault();\n      moveDrag(touch.clientY, event.timeStamp);\n    };\n    const end = (event: TouchEvent) => {\n      if (gesture?.mode === \"sheet\") endDrag(event.timeStamp);\n      gesture = null;\n    };\n    body.addEventListener(\"touchstart\", start, { passive: true });\n    body.addEventListener(\"touchmove\", move, { passive: false });\n    body.addEventListener(\"touchend\", end);\n    body.addEventListener(\"touchcancel\", end);\n    return () => {\n      body.removeEventListener(\"touchstart\", start);\n      body.removeEventListener(\"touchmove\", move);\n      body.removeEventListener(\"touchend\", end);\n      body.removeEventListener(\"touchcancel\", end);\n    };\n  }, [beginDrag, endDrag, moveDrag, top]);\n\n  // The scroll hairline under the header is toggled on the element directly, so scrolling never re-renders.\n  useEffect(() => {\n    const body = bodyRef.current,\n      sheet = sheetRef.current;\n    if (!body || !sheet) return;\n    const update = () => sheet.toggleAttribute(\"data-scrolled\", body.scrollTop > 1);\n    body.addEventListener(\"scroll\", update, { passive: true });\n    return () => body.removeEventListener(\"scroll\", update);\n  }, []);\n\n  function headerDown(event: ReactPointerEvent<HTMLDivElement>) {\n    const target = event.target instanceof Element ? event.target : null;\n    if (event.button !== 0 || !event.isPrimary) return;\n    if (target?.closest(\"button, a, input, select, textarea, [role='button']\") && !target.closest(\"[data-grabber]\"))\n      return;\n    beginDrag(event.clientY, event.timeStamp);\n    // Moves arrive once per frame, so a fast flick can leave the header before its first move lands.\n    // Until the header captures the pointer, the window follows it; once captured, the header's own handlers take over.\n    const header = event.currentTarget,\n      id = event.pointerId;\n    const outside = (next: PointerEvent) =>\n      next.pointerId === id && !(next.target instanceof Node && header.contains(next.target));\n    const move = (next: PointerEvent) => {\n      if (!outside(next) || !drag.current) return;\n      moveDrag(next.clientY, next.timeStamp);\n      if (drag.current?.moved && !header.hasPointerCapture(id)) {\n        try {\n          header.setPointerCapture(id);\n        } catch {\n          /* The pointer is already gone. */\n        }\n      }\n    };\n    const up = (next: PointerEvent) => {\n      if (next.pointerId !== id) return;\n      window.removeEventListener(\"pointermove\", move);\n      window.removeEventListener(\"pointerup\", up);\n      window.removeEventListener(\"pointercancel\", up);\n      if (outside(next)) endDrag(next.timeStamp);\n    };\n    window.addEventListener(\"pointermove\", move);\n    window.addEventListener(\"pointerup\", up);\n    window.addEventListener(\"pointercancel\", up);\n  }\n  // Capture only once the pointer has really moved, so a plain click still lands on the grabber button.\n  function headerMove(event: ReactPointerEvent<HTMLDivElement>) {\n    if (!drag.current) return;\n    if (event.pointerType === \"mouse\" && event.buttons === 0) {\n      endDrag(event.timeStamp);\n      return;\n    }\n    moveDrag(event.clientY, event.timeStamp);\n    if (drag.current.moved && !event.currentTarget.hasPointerCapture(event.pointerId))\n      event.currentTarget.setPointerCapture(event.pointerId);\n  }\n  function headerUp(event: ReactPointerEvent<HTMLDivElement>) {\n    if (event.currentTarget.hasPointerCapture(event.pointerId))\n      event.currentTarget.releasePointerCapture(event.pointerId);\n    endDrag(event.timeStamp);\n  }\n\n  function grabberClick() {\n    if (suppressClick.current) {\n      suppressClick.current = false;\n      return;\n    }\n    rest(detentRef.current === top ? 0 : top);\n  }\n  function grabberKey(event: KeyboardEvent<HTMLButtonElement>) {\n    const current = detentRef.current;\n    const next =\n      event.key === \"ArrowUp\"\n        ? Math.min(current + 1, top)\n        : event.key === \"ArrowDown\"\n          ? Math.max(current - 1, 0)\n          : event.key === \"Home\"\n            ? top\n            : event.key === \"End\"\n              ? 0\n              : null;\n    if (next === null) return;\n    event.preventDefault();\n    rest(next);\n  }\n\n  // Scrolling or tabbing into content that is still below the fold opens the sheet the rest of the way.\n  function bodyWheel(event: WheelEvent<HTMLDivElement>) {\n    if (detentRef.current !== top && event.deltaY > 4 && !drag.current) rest(top);\n  }\n  function bodyFocus(event: FocusEvent<HTMLDivElement>) {\n    if (detentRef.current === top || !(event.target instanceof Element)) return;\n    if (event.target.getBoundingClientRect().bottom > window.innerHeight - 8) rest(top);\n  }\n\n  // With focus on the sheet or its content, reading keys open the sheet first and then scroll the content.\n  function sheetKey(event: KeyboardEvent<HTMLDivElement>) {\n    const body = bodyRef.current;\n    if (!body || (event.target !== event.currentTarget && event.target !== body)) return;\n    const down = event.key === \"ArrowDown\" || event.key === \"PageDown\" || (event.key === \" \" && !event.shiftKey);\n    const up = event.key === \"ArrowUp\" || event.key === \"PageUp\" || (event.key === \" \" && event.shiftKey);\n    if (!down && !up) return;\n    event.preventDefault();\n    if (down && detentRef.current !== top) {\n      rest(top);\n      return;\n    }\n    const step = event.key.startsWith(\"Arrow\") ? 48 : body.clientHeight * 0.85;\n    body.scrollBy({ top: down ? step : -step, behavior: reduced ? \"auto\" : \"smooth\" });\n  }\n\n  const expanded = detent === top;\n  const style = {\n    y,\n    opacity: presence,\n    \"--sheet-max\": stops[top],\n    \"--sheet-extension\": `${EXTENSION}px`,\n  } as unknown as CSSProperties;\n\n  return (\n    <>\n      <DialogPrimitive.Overlay asChild forceMount>\n        {/* The dim follows the sheet's position in JS; only its color lives here. While it leaves, nothing underneath is blocked and the sheet cannot be grabbed again.\n          Radix sets pointer-events: auto inline on the overlay, so only !important lets a click reach the trigger and reverse the exit. */}\n        <motion.div\n          className=\"fixed inset-0 z-50 bg-[oklch(16%_0_0/.34)] data-[state=closed]:pointer-events-none! dark:bg-[oklch(0%_0_0/.52)]\"\n          style={{ opacity: backdrop }}\n        />\n      </DialogPrimitive.Overlay>\n      <DialogPrimitive.Content\n        asChild\n        forceMount\n        {...(description ? {} : { \"aria-describedby\": undefined })}\n        onOpenAutoFocus={(event) => {\n          event.preventDefault();\n          sheetRef.current?.focus({ preventScroll: true });\n        }}\n        // Radix waits for the click before treating a press as outside. A press on the trigger while the sheet leaves reopens it first, so that press must not close it again.\n        onPointerDownOutside={(event) => {\n          const { present, at } = presenceChange.current;\n          if (!present || event.detail.originalEvent.timeStamp < at) event.preventDefault();\n        }}\n      >\n        {/* The sheet is as tall as its largest detent plus a hidden extension below the fold; detents are translateY offsets from there. */}\n        <motion.div\n          ref={sheetRef}\n          className={cn(\n            \"group/sheet fixed right-0 -bottom-(--sheet-extension) left-0 z-51 mx-auto flex h-[calc(var(--sheet-max)_*_100dvh_+_var(--sheet-extension))] w-[min(100%,36rem)] flex-col overflow-hidden rounded-t-surface border border-b-0 border-line bg-surface pb-[calc(var(--sheet-extension)_+_env(safe-area-inset-bottom,0px))] font-body tracking-body text-ink shadow-floating outline-none will-change-transform data-[state=closed]:pointer-events-none!\",\n            className,\n          )}\n          style={style}\n          data-expanded={expanded ? \"\" : undefined}\n          onKeyDown={sheetKey}\n        >\n          {/* The whole header is the handle, so a drag can start anywhere above the content. A hairline appears only once the content scrolls under it. */}\n          <div\n            className=\"relative flex-none cursor-grab touch-none px-5 pt-2 pb-4 select-none group-data-dragging/sheet:cursor-grabbing after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-line after:opacity-0 after:transition-opacity after:duration-fast after:ease-standard after:content-[''] group-data-scrolled/sheet:after:opacity-100 max-sm:px-4 motion-reduce:after:transition-none\"\n            onPointerDown={headerDown}\n            onPointerMove={headerMove}\n            onPointerUp={headerUp}\n            onPointerCancel={headerUp}\n          >\n            <button\n              type=\"button\"\n              className=\"group/grabber mx-auto mb-1 flex h-5 w-14 cursor-[inherit] items-center justify-center rounded-button border-0 bg-transparent p-0 [-webkit-tap-highlight-color:transparent]\"\n              data-grabber=\"\"\n              aria-label={expanded ? \"Collapse sheet\" : \"Expand sheet\"}\n              aria-expanded={expanded}\n              onClick={grabberClick}\n              onKeyDown={grabberKey}\n            >\n              <span\n                className=\"h-[5px] w-9 rounded-full bg-line-strong transition-[background-color] duration-fast ease-standard group-hover/grabber:bg-ink-3 group-data-dragging/sheet:bg-ink-3 motion-reduce:transition-none\"\n                aria-hidden=\"true\"\n              />\n            </button>\n            <div className=\"flex items-start justify-between gap-4\">\n              <div className=\"min-w-0\">\n                <DialogPrimitive.Title className=\"m-0 font-body text-lg leading-body font-medium tracking-body\">\n                  {title}\n                </DialogPrimitive.Title>\n                {description ? (\n                  <DialogPrimitive.Description className=\"mx-0 mt-0.5 mb-0 text-sm leading-body text-ink-2\">\n                    {description}\n                  </DialogPrimitive.Description>\n                ) : null}\n              </div>\n              <DialogPrimitive.Close\n                className=\"-mt-0.5 grid size-8 flex-none cursor-pointer place-items-center rounded-button border border-line bg-hover p-0 text-ink-2 [-webkit-tap-highlight-color:transparent] transition-[background-color,color] duration-fast ease-standard hover:border-line-strong hover:text-ink active:text-ink motion-reduce:transition-none\"\n                aria-label={closeLabel}\n              >\n                <X size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n              </DialogPrimitive.Close>\n            </div>\n          </div>\n          {/* The content scrolls only at the tallest detent; below it, a drag or scroll opens the sheet first. */}\n          <div\n            ref={bodyRef}\n            className={cn(\n              \"min-h-0 flex-auto overflow-y-auto overscroll-contain px-5 pt-1 pb-6 text-sm leading-body [scrollbar-width:thin] touch-pan-y max-sm:px-4\",\n              !expanded && \"touch-none overflow-hidden\",\n            )}\n            onWheel={bodyWheel}\n            onFocus={bodyFocus}\n          >\n            {children}\n          </div>\n          <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n            {announcement}\n          </span>\n        </motion.div>\n      </DialogPrimitive.Content>\n    </>\n  );\n}\n\nexport const BottomSheetClose = DialogPrimitive.Close;\n\nexport default BottomSheet;\n",
      "type": "registry:component",
      "target": "@components/iris/bottom-sheet/bottom-sheet.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/bottom-sheet",
  "meta": {
    "kind": "component",
    "group": "Overlays & disclosure",
    "export": "BottomSheet",
    "tags": [
      "overlay",
      "gesture",
      "motion"
    ],
    "docs": "https://irisui.dev/components/bottom-sheet"
  }
}
