{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sheet",
  "title": "Sheet",
  "description": "An animated sheet for React and shadcn, sliding in from an edge and becoming a bottom sheet on small screens.",
  "dependencies": [
    "@base-ui/react"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-overlay-core.json",
    "https://eased.dev/r/eased-scroll-fade.json",
    "https://eased.dev/r/button.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/sheet/sheet.tsx",
      "content": "\"use client\";\n\nimport { Drawer as BaseDrawer } from \"@base-ui/react/drawer\";\nimport { createContext, use, useEffect, useRef, useState, useSyncExternalStore, type ComponentProps, type ReactNode } from \"react\";\nimport { backdropClass } from \"../_primitives/overlay-core/overlay-core\";\nimport { scrollFadeMask, useScrollEdges } from \"../_primitives/scroll-fade/scroll-fade\";\nimport { Button, type ButtonSize, type ButtonVariant } from \"../button/button\";\n\n/**\n * A sheet on Base UI's Drawer: a floating panel that slides in from an edge over the blurred\n * scrim, follows the finger when swiped back toward that edge, and leaves faster the harder\n * it is flicked. Under 640px a side sheet becomes a bottom sheet with a grabber. A sheet\n * opened from inside another steps the parent back, smaller and dimmed, like iOS.\n *\n * Motion numbers are overlay-core's `SHEET`: 500ms in on the enter curve, out in\n * `--drawer-swipe-strength` × 375ms on the standard curve; nested parents step back 16px and\n * 5% per level; the background indent scales to 0.97 and drops 8px.\n */\n\nexport type SheetSide = \"right\" | \"left\" | \"top\" | \"bottom\";\n\nconst SWIPE = { right: \"right\", left: \"left\", top: \"up\", bottom: \"down\" } as const;\n\ntype SheetContextValue = { setSide: (side: SheetSide) => void; snap: boolean };\nconst SheetContext = createContext<SheetContextValue | null>(null);\n\nexport type SheetProps = Omit<ComponentProps<typeof BaseDrawer.Root>, \"swipeDirection\">;\n\n/**\n * Groups a sheet's parts and owns its open state (`open` / `defaultOpen` / `onOpenChange`).\n * The swipe direction follows `SheetContent`'s side. `snapPoints`, `snapPoint` and\n * `onSnapPointChange` pass through to Base UI for bottom sheets.\n */\nexport function Sheet({ snapPoints, ...props }: SheetProps) {\n  const [side, setSide] = useState<SheetSide>(\"right\");\n  return (\n    <SheetContext value={{ setSide, snap: (snapPoints?.length ?? 0) > 0 }}>\n      <BaseDrawer.Root {...props} snapPoints={snapPoints} swipeDirection={SWIPE[side]} />\n    </SheetContext>\n  );\n}\n\n/** Closes the sheet. `<SheetClose render={<Button />}>Cancel</SheetClose>`. */\nexport const SheetClose = BaseDrawer.Close;\n\nexport type SheetTriggerProps = Omit<ComponentProps<typeof BaseDrawer.Trigger>, \"className\"> & {\n  /** The Eased `Button` variant. Default \"secondary\". */\n  variant?: ButtonVariant;\n  /** The Eased `Button` size. Default \"sm\". */\n  size?: ButtonSize;\n  className?: string;\n};\n\n/**\n * The button that opens the sheet: an Eased `Button`, which never scales as a popup trigger\n * (R64). Focus returns here on close. Pass `render` to bring your own element.\n */\nexport function SheetTrigger({ variant = \"secondary\", size = \"sm\", className = \"\", render, ...props }: SheetTriggerProps) {\n  return <BaseDrawer.Trigger {...props} render={render ?? <Button variant={variant} size={size} className={className} />} />;\n}\n\n/* ------------------------------------------------------------------ Mobile ---------- */\n\nconst MOBILE = \"(max-width: 639.98px)\";\nconst subscribeMobile = (onChange: () => void) => {\n  const mq = window.matchMedia(MOBILE);\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n};\n\n/** Under 640px, read after hydration: false on the server and while hydrating, live afterwards. */\nfunction useMobile() {\n  return useSyncExternalStore(subscribeMobile, () => window.matchMedia(MOBILE).matches, () => false);\n}\n\n/* ------------------------------------------------------------------ Motion ---------- */\n\n/*\n * Every side writes the same working variables, read by the shared classes below:\n *   --sheet-rest  the resting transform: Base UI's swipe movement, minus the stack step\n *   --sheet-off   where it enters from and leaves to (just past its edge)\n *   --sheet-head  the header's top inset (the close button lines up with the title)\n * They are `transform`, not `translate`/`scale`: Base UI freezes the popup's computed\n * `transform` when a drag starts and writes the drag over it inline.\n * --sheet-depth is the number of sheets open above this one, easing back to 0 as the top one\n * is swiped away (Base UI gives a parent its child's swipe progress).\n */\nconst POPUP = [\n  \"relative flex min-h-0 flex-col border border-line bg-surface-raised text-ui text-text shadow-overlay outline-none\",\n  \"[--radius-outer:var(--eased-radius-surface)] [--pad:24px] max-sm:[--pad:20px]\",\n  \"[--sheet-depth:calc(var(--nested-drawers)_-_clamp(0,var(--drawer-swipe-progress),1))] [--sheet-step:16px] [--sheet-shrink:0.05]\",\n  // Side sheets step further, so the parent still peeks out past a same-width child.\n  \"data-[side=right]:[--sheet-step:40px] data-[side=left]:[--sheet-step:40px] reduced:data-[side=right]:[--sheet-step:0px] reduced:data-[side=left]:[--sheet-step:0px]\",\n  \"[--sheet-in:var(--eased-duration-sheet)] [--sheet-out:calc(var(--drawer-swipe-strength)*375ms)]\",\n  \"[transform:var(--sheet-rest)] data-starting-style:[transform:var(--sheet-off)] data-ending-style:[transform:var(--sheet-off)]\",\n  \"[transition-property:transform,opacity] [transition-duration:var(--sheet-in)] [transition-timing-function:var(--eased-ease-enter)]\",\n  \"data-ending-style:[transition-duration:var(--sheet-out)] data-ending-style:[transition-timing-function:var(--eased-ease-standard)]\",\n  // Transitions are off while a finger drives the sheet (or the sheet above it).\n  \"data-swiping:select-none data-swiping:[transition-duration:0ms] data-nested-drawer-swiping:[transition-duration:0ms]\",\n  \"data-ending-style:data-swiping:[transition-duration:var(--sheet-out)] data-ending-style:data-nested-drawer-swiping:[transition-duration:var(--sheet-out)]\",\n  // A parent under a nested sheet dims by half a scrim, tracking the child's swipe.\n  \"after:pointer-events-none after:absolute after:-inset-px after:rounded-[inherit] after:bg-scrim after:opacity-[calc(min(1,var(--sheet-depth))*0.5)] after:[transition:opacity_var(--eased-duration-slow)_var(--eased-ease-standard)] data-nested-drawer-swiping:after:[transition:none]\",\n  // Reduced motion: no slide, no step back; a 100ms fade.\n  \"reduced:[--sheet-off:var(--sheet-rest)] reduced:[--sheet-step:0px] reduced:[--sheet-shrink:0] reduced:[--sheet-in:var(--eased-duration-instant)] reduced:[--sheet-out:var(--eased-duration-instant)] reduced:data-starting-style:opacity-0 reduced:data-ending-style:opacity-0\",\n].join(\" \");\n\nconst SIDES: Record<SheetSide, { viewport: string; popup: string }> = {\n  right: {\n    viewport: \"justify-end items-stretch p-(--sheet-inset) [--sheet-inset:0px] sm:[--sheet-inset:8px]\",\n    popup:\n      \"h-full w-(--sheet-w) max-w-full max-sm:max-w-[calc(100%_-_16px)] rounded-l-surface max-sm:border-r-0 sm:rounded-surface [--sheet-head:var(--pad)] [transform-origin:100%_50%] [--sheet-rest:translateX(calc(var(--drawer-swipe-movement-x)_-_var(--sheet-depth)*var(--sheet-step)))_scale(calc(1_-_var(--sheet-depth)*var(--sheet-shrink)))] [--sheet-off:translateX(calc(100%_+_var(--sheet-inset)_+_16px))]\",\n  },\n  left: {\n    viewport: \"justify-start items-stretch p-(--sheet-inset) [--sheet-inset:0px] sm:[--sheet-inset:8px]\",\n    popup:\n      \"h-full w-(--sheet-w) max-w-full max-sm:max-w-[calc(100%_-_16px)] rounded-r-surface max-sm:border-l-0 sm:rounded-surface [--sheet-head:var(--pad)] [transform-origin:0%_50%] [--sheet-rest:translateX(calc(var(--drawer-swipe-movement-x)_+_var(--sheet-depth)*var(--sheet-step)))_scale(calc(1_-_var(--sheet-depth)*var(--sheet-shrink)))] [--sheet-off:translateX(calc(-100%_-_var(--sheet-inset)_-_16px))]\",\n  },\n  // The bleed (::before) runs 48px past the screen edge, so an overshoot never shows a gap.\n  bottom: {\n    viewport: \"items-end justify-center\",\n    popup:\n      \"w-full max-h-[min(85dvh,calc(100%_-_24px))] rounded-t-surface border-b-0 pb-[env(safe-area-inset-bottom,0px)] data-snap:pb-[calc(env(safe-area-inset-bottom,0px)_+_max(0px,calc(var(--drawer-snap-point-offset)_+_var(--drawer-swipe-movement-y))))] [--sheet-head:28px] [transform-origin:50%_0%] [--sheet-rest:translateY(calc(var(--drawer-snap-point-offset)_+_var(--drawer-swipe-movement-y)_-_var(--sheet-depth)*var(--sheet-step)))_scale(calc(1_-_var(--sheet-depth)*var(--sheet-shrink)))] [--sheet-off:translateY(calc(100%_+_2px))] before:pointer-events-none before:absolute before:-inset-x-px before:top-full before:h-12 before:border-x before:border-line before:bg-inherit\",\n  },\n  top: {\n    viewport: \"items-start justify-center\",\n    popup:\n      \"w-full max-h-[min(85dvh,calc(100%_-_24px))] rounded-b-surface border-t-0 pt-[env(safe-area-inset-top,0px)] [--sheet-head:var(--pad)] [transform-origin:50%_100%] [--sheet-rest:translateY(calc(var(--drawer-swipe-movement-y)_+_var(--sheet-depth)*var(--sheet-step)))_scale(calc(1_-_var(--sheet-depth)*var(--sheet-shrink)))] [--sheet-off:translateY(calc(-100%_-_2px))] before:pointer-events-none before:absolute before:-inset-x-px before:bottom-full before:h-12 before:border-x before:border-line before:bg-inherit\",\n  },\n};\n\nconst WIDTHS = { sm: \"[--sheet-w:20rem]\", md: \"[--sheet-w:24rem]\", lg: \"[--sheet-w:30rem]\" } as const;\nexport type SheetSize = keyof typeof WIDTHS;\n\n/* The scrim fades with the swipe: Base UI writes --drawer-swipe-progress on the backdrop. */\nconst BACKDROP = `${backdropClass} opacity-[calc(1_-_var(--drawer-swipe-progress))] data-swiping:[transition-duration:0ms]`;\n\nexport type SheetContentProps = {\n  /** The edge it lives on and swipes back to. Default \"right\". */\n  side?: SheetSide;\n  /** Side sheets' width: sm 320px, md 384px (default), lg 480px. Never wider than the screen less 16px. */\n  size?: SheetSize;\n  /**\n   * Under 640px, a right or left sheet becomes a bottom sheet with a grabber and drag to\n   * dismiss. Read after hydration, so the server HTML never mismatches. Default true.\n   */\n  responsive?: boolean;\n  /** Portal target. Default `document.body`. */\n  container?: HTMLElement | null;\n  /** Where focus goes on open. Default: the first tabbable element (the popup itself on touch). */\n  initialFocus?: ComponentProps<typeof BaseDrawer.Popup>[\"initialFocus\"];\n  /** Where focus returns on close. Default: the trigger. */\n  finalFocus?: ComponentProps<typeof BaseDrawer.Popup>[\"finalFocus\"];\n  /** Show the close button in the top corner. Default true. */\n  closeButton?: boolean;\n  /** The close button's accessible name. Default \"Close\". */\n  closeLabel?: string;\n  /** Appended to the popup's classes. */\n  className?: string;\n  /** `SheetHeader`, `SheetBody` and `SheetFooter`. */\n  children?: ReactNode;\n};\n\n/**\n * The sheet: scrim, viewport and popup. A raised 24px-corner surface (inset 8px from the\n * screen from 640px up), `shadow-overlay`, with `--pad` (24px, 20px under 640px) and\n * `--radius-outer` exposed for concentric children. Lay it out with `SheetHeader`,\n * `SheetBody` (the scroll region) and `SheetFooter`. The close button's icon sits on the\n * content's right edge (R104).\n */\nexport function SheetContent({\n  side = \"right\",\n  size = \"md\",\n  responsive = true,\n  container,\n  initialFocus,\n  finalFocus,\n  closeButton = true,\n  closeLabel = \"Close\",\n  className = \"\",\n  children,\n}: SheetContentProps) {\n  const ctx = use(SheetContext);\n  if (!ctx) throw new Error(\"<SheetContent> must be rendered inside a <Sheet>.\");\n  const mobile = useMobile();\n  const placed: SheetSide = responsive && mobile && (side === \"left\" || side === \"right\") ? \"bottom\" : side;\n  const { setSide } = ctx;\n  useEffect(() => setSide(placed), [setSide, placed]);\n  const s = SIDES[placed];\n\n  return (\n    <BaseDrawer.Portal container={container}>\n      <BaseDrawer.Backdrop className={BACKDROP} />\n      <BaseDrawer.Viewport className={`fixed inset-0 z-50 flex ${s.viewport}`}>\n        <BaseDrawer.Popup\n          initialFocus={initialFocus}\n          finalFocus={finalFocus}\n          data-side={placed}\n          data-snap={(ctx.snap && placed === \"bottom\") || undefined}\n          className={`${POPUP} ${s.popup} ${WIDTHS[size]} ${className}`}\n        >\n          {placed === \"bottom\" && (\n            // Outside Drawer.Content, so a mouse can drag from it too (Content keeps text selectable).\n            <div aria-hidden className=\"pointer-events-none absolute inset-x-0 top-0 flex h-5 items-end justify-center\">\n              <span className=\"pointer-events-auto h-1 w-9 rounded-pill bg-line-strong hit-area\" />\n            </div>\n          )}\n          <BaseDrawer.Content className=\"flex min-h-0 flex-1 flex-col\">{children}</BaseDrawer.Content>\n          {closeButton && (\n            // A positioned wrapper: Button is `relative` itself, so it can't be the absolute layer.\n            <span className=\"absolute end-[calc(var(--pad)-8px)] top-[calc(var(--sheet-head)-4px)] flex\">\n              <BaseDrawer.Close render={<Button variant=\"ghost\" size=\"icon-sm\" hitArea={false} aria-label={closeLabel} />}>\n                <CloseIcon />\n              </BaseDrawer.Close>\n            </span>\n          )}\n        </BaseDrawer.Popup>\n      </BaseDrawer.Viewport>\n    </BaseDrawer.Portal>\n  );\n}\n\n/* ------------------------------------------------------------------- Parts ---------- */\n\ntype TextProps = { className?: string; children: ReactNode };\n\n/** Title and description, on the content's inset. Fixed above the scrolling body. */\nexport function SheetHeader({ className = \"\", children }: TextProps) {\n  return <div className={`flex shrink-0 flex-col px-(--pad) pt-(--sheet-head) pb-4 ${className}`}>{children}</div>;\n}\n\n/** The heading; labels the sheet. Leaves room for the close button. */\nexport function SheetTitle({ className = \"\", children }: TextProps) {\n  return <BaseDrawer.Title className={`pe-8 text-ui-md font-medium text-balance text-text ${className}`}>{children}</BaseDrawer.Title>;\n}\n\n/** Supporting text under the title; describes the sheet. */\nexport function SheetDescription({ className = \"\", children }: TextProps) {\n  return <BaseDrawer.Description className={`mt-1.5 text-ui text-pretty text-text-secondary ${className}`}>{children}</BaseDrawer.Description>;\n}\n\n/**\n * The scroll region between the header and the footer. Its edges feather only while there is\n * more to scroll; at the top, a downward drag hands over to the sheet (Base UI).\n */\nexport function SheetBody({ className = \"\", children }: TextProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  useScrollEdges(ref);\n  return (\n    <div\n      ref={ref}\n      style={scrollFadeMask(\"y\")}\n      className={`flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto overscroll-contain px-(--pad) pt-1 pb-(--pad) ${className}`}\n    >\n      {children}\n    </div>\n  );\n}\n\n/**\n * The actions bar, pinned under the body on the same inset (R105), above a hairline. Put the\n * primary action last; a leading ghost action takes `edge=\"start\"` and `className=\"me-auto\"`.\n */\nexport function SheetFooter({ className = \"\", children }: TextProps) {\n  return (\n    <div className={`flex shrink-0 items-center justify-end gap-2 border-t border-line-subtle px-(--pad) py-4 ${className}`}>{children}</div>\n  );\n}\n\n/* ------------------------------------------------------------------ Indent ---------- */\n\n/**\n * Lets an open sheet indent the page behind it. Wrap the app (above every `Sheet`), then put\n * the page in `SheetIndent`.\n */\nexport const SheetProvider = BaseDrawer.Provider;\n\nexport type SheetIndentProps = {\n  /** Classes for the page layer. Give it the page's background (default `bg-bg`). */\n  className?: string;\n  /** Classes for what shows around the indented page. Default `bg-surface-sunken`. */\n  backgroundClassName?: string;\n  children: ReactNode;\n};\n\n/**\n * The page, scaled to 0.97 and dropped 8px with 24px corners while any sheet in the\n * `SheetProvider` is open, easing back as the sheet is swiped away. Scales from its top\n * centre, so put it on an app shell that scrolls itself (`h-dvh overflow-y-auto`) rather than\n * on a page the window scrolls. Reduced motion skips it.\n */\nexport function SheetIndent({ className = \"bg-bg\", backgroundClassName = \"bg-surface-sunken\", children }: SheetIndentProps) {\n  return (\n    <div className=\"relative isolate\">\n      <BaseDrawer.IndentBackground className={`absolute inset-0 -z-1 ${backgroundClassName}`} />\n      <BaseDrawer.Indent\n        className={`relative origin-[50%_0%] [--indent-t:calc(1_-_clamp(0,calc(var(--drawer-swipe-progress)*100000),1))] [transition-property:transform,border-radius] [transition-duration:calc(var(--eased-duration-sheet)*var(--indent-t))] [transition-timing-function:var(--eased-ease-enter)] data-active:overflow-hidden data-active:rounded-surface data-active:[transform:translateY(calc(8px*(1_-_var(--drawer-swipe-progress))))_scale(calc(0.97_+_0.03*var(--drawer-swipe-progress)))] reduced:data-active:[transform:none] ${className}`}\n      >\n        {children}\n      </BaseDrawer.Indent>\n    </div>\n  );\n}\n\nfunction CloseIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" className=\"size-4 shrink-0\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" aria-hidden>\n      <path d=\"m4.5 4.5 7 7m0-7-7 7\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/sheet/sheet.tsx"
    }
  ],
  "meta": {
    "details": "A sheet on Base UI Drawer that slides in from its edge in 500ms and leaves faster the harder it is flicked, the scrim fading with the swipe. Under 640px side sheets become bottom sheets; nested sheets step their parent back.",
    "industries": [
      "saas",
      "ecommerce",
      "real-estate",
      "travel"
    ],
    "releaseAt": "2026-10-21"
  },
  "type": "registry:ui"
}