{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/bottom-sheet/bottom-sheet.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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 { CSSProperties, FocusEvent, KeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode, WheelEvent } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, usePresence, useReducedMotion, useTransform } from \"motion/react\";\nimport { X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./bottom-sheet.module.css\";\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 = .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 = .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: .3 };\nconst fade = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] as [number, number, number, number] };\n\n/** iOS-style resistance: follows the finger at first, then approaches STRETCH. */\nconst rubber = (distance: number) => (1 - 1 / (distance * .55 / STRETCH + 1)) * STRETCH;\nconst unrubber = (stretch: number) => (1 / (1 - Math.min(stretch, STRETCH - 1) / STRETCH) - 1) * STRETCH / .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], 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({ trigger, open: openProp, defaultOpen = false, onOpenChange, ...props }: BottomSheetProps) {\n  const [uncontrolled, setUncontrolled] = useState(defaultOpen);\n  const open = openProp ?? uncontrolled;\n  const setOpen = useCallback((next: boolean) => {\n    if (openProp === undefined) setUncontrolled(next);\n    onOpenChange?.(next);\n  }, [openProp, onOpenChange]);\n  const dismiss = useCallback(() => setOpen(false), [setOpen]);\n  return <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>{open && <DialogPrimitive.Portal key=\"sheet\" forceMount><Sheet {...props} onDismiss={dismiss} /></DialogPrimitive.Portal>}</AnimatePresence>\n  </DialogPrimitive.Root>;\n}\n\nfunction Sheet({ title, description, detents = [.45, .92], initialDetent = 0, onDetentChange, closeLabel = \"Close\", className, children, onDismiss }: 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(() => detentKey.split(\",\").map(Number).filter(stop => stop > 0).sort((a, b) => a - b), [detentKey]);\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(() => { presenceChange.current = { present: isPresent, at: performance.now() }; }, [isPresent]);\n\n  const offset = useCallback((stop: Stop) => {\n    const full = height.get();\n    return stop === \"closed\" ? full + CLOSED_GAP : full * (1 - stops[stop]! / stops[top]!);\n  }, [height, stops, top]);\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]!), closed = full + CLOSED_GAP;\n    const dim = 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((stop: Stop, velocity?: number, onArrive?: () => void) => {\n    aim.current = stop;\n    arrive.current = onArrive;\n    const target = offset(stop);\n    const done = () => { const callback = arrive.current; arrive.current = undefined; callback?.(); };\n    if (reduced) { y.jump(target); done(); return; }\n    animate(y, target, { ...(stop === \"closed\" ? leave : settle), ...(velocity === undefined ? {} : { velocity }), onComplete: done });\n  }, [offset, reduced, y]);\n\n  const rest = useCallback((stop: number, velocity?: number) => {\n    if (stop !== detentRef.current) {\n      detentRef.current = stop;\n      setDetent(stop);\n      onDetentChange?.(stop);\n      setAnnouncement(stop === top ? \"Sheet expanded\" : stop === 0 ? \"Sheet collapsed\" : `Sheet at ${Math.round(stops[stop]! * 100)} percent height`);\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  }, [go, onDetentChange, reduced, stops, top]);\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) { y.jump(offset(detentRef.current)); presence.jump(0); }\n    }\n    if (isPresent) {\n      if (reduced) { y.jump(offset(detentRef.current)); animate(presence, 1, fade); return; }\n      presence.jump(1);\n      go(detentRef.current);\n      return;\n    }\n    if (reduced) { animate(presence, 0, { ...fade, onComplete: safeToRemove }); return; }\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); else y.jump(offset(stop));\n    });\n    observer.observe(sheet);\n    return () => observer.disconnect();\n  }, [go, height, offset, y]);\n\n  const beginDrag = useCallback((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 = { startY: clientY, origin: current < 0 ? -unrubber(-current) : current, from: detentRef.current, moved: false, samples: [{ t: time, y: clientY }] };\n  }, [y]);\n\n  const moveDrag = useCallback((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  }, [y]);\n\n  const endDrag = useCallback((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) { const stop = aim.current; if (stop !== \"closed\") go(stop); return; }\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) => Math.abs(offset(stop) - projected) < Math.abs(offset(best) - projected) ? stop : best);\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) 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\") { if (!reduced) go(\"closed\", velocity); onDismiss(); return; }\n    rest(target, velocity);\n  }, [go, offset, onDismiss, reduced, rest, stops, top, y]);\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, 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 = 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, 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]\")) 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, id = event.pointerId;\n    const outside = (next: PointerEvent) => 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)) { try { header.setPointerCapture(id); } catch { /* The pointer is already gone. */ } }\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) { endDrag(event.timeStamp); return; }\n    moveDrag(event.clientY, event.timeStamp);\n    if (drag.current.moved && !event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.setPointerCapture(event.pointerId);\n  }\n  function headerUp(event: ReactPointerEvent<HTMLDivElement>) {\n    if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);\n    endDrag(event.timeStamp);\n  }\n\n  function grabberClick() {\n    if (suppressClick.current) { suppressClick.current = false; return; }\n    rest(detentRef.current === top ? 0 : top);\n  }\n  function grabberKey(event: KeyboardEvent<HTMLButtonElement>) {\n    const current = detentRef.current;\n    const next = event.key === \"ArrowUp\" ? Math.min(current + 1, top) : event.key === \"ArrowDown\" ? Math.max(current - 1, 0) : event.key === \"Home\" ? top : event.key === \"End\" ? 0 : 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) { rest(top); return; }\n    const step = event.key.startsWith(\"Arrow\") ? 48 : body.clientHeight * .85;\n    body.scrollBy({ top: down ? step : -step, behavior: reduced ? \"auto\" : \"smooth\" });\n  }\n\n  const expanded = detent === top;\n  const style = { y, opacity: presence, \"--sheet-max\": stops[top], \"--sheet-extension\": `${EXTENSION}px` } as unknown as CSSProperties;\n\n  return <>\n    <DialogPrimitive.Overlay asChild forceMount>\n      <motion.div className={styles.overlay} style={{ opacity: backdrop }} />\n    </DialogPrimitive.Overlay>\n    <DialogPrimitive.Content\n      asChild\n      forceMount\n      {...(description ? {} : { \"aria-describedby\": undefined })}\n      onOpenAutoFocus={event => { event.preventDefault(); sheetRef.current?.focus({ preventScroll: true }); }}\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 => { const { present, at } = presenceChange.current; if (!present || event.detail.originalEvent.timeStamp < at) event.preventDefault(); }}\n    >\n      <motion.div ref={sheetRef} className={[styles.sheet, className].filter(Boolean).join(\" \")} style={style} data-expanded={expanded ? \"\" : undefined} onKeyDown={sheetKey}>\n        <div className={styles.header} onPointerDown={headerDown} onPointerMove={headerMove} onPointerUp={headerUp} onPointerCancel={headerUp}>\n          <button type=\"button\" className={styles.grabber} data-grabber=\"\" aria-label={expanded ? \"Collapse sheet\" : \"Expand sheet\"} aria-expanded={expanded} onClick={grabberClick} onKeyDown={grabberKey}>\n            <span className={styles.grabberBar} aria-hidden=\"true\" />\n          </button>\n          <div className={styles.headRow}>\n            <div className={styles.headText}>\n              <DialogPrimitive.Title className={styles.title}>{title}</DialogPrimitive.Title>\n              {description ? <DialogPrimitive.Description className={styles.description}>{description}</DialogPrimitive.Description> : null}\n            </div>\n            <DialogPrimitive.Close className={styles.close} aria-label={closeLabel}>\n              <X size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n            </DialogPrimitive.Close>\n          </div>\n        </div>\n        <div ref={bodyRef} className={styles.body} onWheel={bodyWheel} onFocus={bodyFocus}>{children}</div>\n        <span className={styles.srOnly} role=\"status\" aria-live=\"polite\">{announcement}</span>\n      </motion.div>\n    </DialogPrimitive.Content>\n  </>;\n}\n\nexport const BottomSheetClose = DialogPrimitive.Close;\n\nexport default BottomSheet;\n"
    },
    {
      "path": "registry/components/bottom-sheet/bottom-sheet.module.css",
      "type": "registry:component",
      "target": "@components/xzero/bottom-sheet/bottom-sheet.module.css",
      "content": "/* The dim follows the sheet's position in JS; only its color lives here. */\n.overlay { position: fixed; inset: 0; z-index: 50; background: oklch(16% 0 0 / .34); }\n:global(:root[data-theme=\"dark\"]) .overlay { background: oklch(0% 0 0 / .52); }\n/* 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.overlay[data-state=\"closed\"], .sheet[data-state=\"closed\"] { pointer-events: none !important; }\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.sheet {\n  position: fixed;\n  z-index: 51;\n  right: 0;\n  bottom: calc(var(--sheet-extension) * -1);\n  left: 0;\n  display: flex;\n  flex-direction: column;\n  width: min(100%, 36rem);\n  height: calc(var(--sheet-max) * 100vh + var(--sheet-extension));\n  height: calc(var(--sheet-max) * 100dvh + var(--sheet-extension));\n  margin-inline: auto;\n  overflow: hidden;\n  padding-bottom: calc(var(--sheet-extension) + env(safe-area-inset-bottom, 0px));\n  border: 1px solid var(--border);\n  border-bottom: 0;\n  border-radius: var(--radius-surface) var(--radius-surface) 0 0;\n  background: var(--surface-raised);\n  color: var(--foreground);\n  box-shadow: var(--shadow-floating);\n  font-family: var(--font-body);\n  letter-spacing: var(--tracking-body);\n  outline: none;\n  will-change: transform;\n}\n\n/* The whole header is the handle, so a drag can start anywhere above the content. */\n.header { position: relative; flex: none; padding: var(--space-2) var(--space-5) var(--space-4); cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; }\n.sheet[data-dragging] .header { cursor: grabbing; }\n/* A hairline appears only once the content scrolls under the header. */\n.header::after { position: absolute; right: 0; bottom: 0; left: 0; height: 1px; background: var(--border); content: \"\"; opacity: 0; transition: opacity var(--duration-fast) var(--ease-standard); }\n.sheet[data-scrolled] .header::after { opacity: 1; }\n\n.grabber { display: flex; width: 3.5rem; height: 1.25rem; align-items: center; justify-content: center; margin: 0 auto var(--space-1); padding: 0; border: 0; border-radius: var(--radius-pill); background: transparent; cursor: inherit; -webkit-tap-highlight-color: transparent; }\n.grabberBar { width: 2.25rem; height: 5px; border-radius: var(--radius-pill); background: var(--border-strong); transition: background-color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .grabber:hover .grabberBar { background: var(--text-muted); } }\n.sheet[data-dragging] .grabberBar { background: var(--text-muted); }\n\n.headRow { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }\n.headText { min-width: 0; }\n.title { margin: 0; font-family: var(--font-body); font-size: var(--text-lg); font-weight: 500; letter-spacing: var(--tracking-body); line-height: var(--leading-body); }\n.description { margin: 2px 0 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }\n\n.close { display: grid; flex: none; width: 2rem; height: 2rem; place-items: center; margin-top: -2px; padding: 0; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface-muted); color: var(--text-secondary); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .close:hover { color: var(--foreground); border-color: var(--border-strong); } }\n.close:active { color: var(--foreground); }\n\n/* The content scrolls only at the tallest detent; below it, a drag or scroll opens the sheet first. */\n.body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: var(--space-1) var(--space-5) var(--space-6); overscroll-behavior: contain; scrollbar-width: thin; touch-action: pan-y; font-size: var(--text-sm); line-height: var(--leading-body); }\n.sheet:not([data-expanded]) .body { overflow: hidden; touch-action: none; }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (max-width: 40rem) {\n  .header { padding-inline: var(--space-4); }\n  .body { padding-inline: var(--space-4); }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .header::after, .grabberBar, .close { transition: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/bottom-sheet",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "overlay",
      "gesture",
      "motion"
    ],
    "docs": "https://ui.xzero.co.uk/components/bottom-sheet",
    "markdown": "https://ui.xzero.co.uk/components/bottom-sheet/markdown"
  }
}