{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "eased-overlay-core",
  "title": "Overlay core",
  "description": "What every Eased overlay shares: panel, menu and modal surfaces, the blurred scrim, menu rows on shared tracks with a gliding highlight, a drawn check and shortcut glyphs, and one motion table from tooltip to toast. Dialog, Sheet, menus, Select, Combobox, Toast and Command Palette are built on it.",
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-floating.json",
    "https://eased.dev/r/eased-indicator.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/_primitives/overlay-core/overlay-core.tsx",
      "content": "\"use client\";\n\nimport type { CSSProperties, HTMLAttributes, ReactElement, ReactNode, Ref } from \"react\";\nimport { duration } from \"../../lib/motion\";\nimport { FLOATING_ROLES, floatingSurface, type FloatingMotion, type FloatingSide } from \"../floating/floating\";\nimport { Indicator, IndicatorGroup } from \"../indicator/indicator\";\n\n/**\n * Overlay core: what every Eased overlay shares, so a row in a menu, a Select, a Combobox and\n * the Command palette is one row, and a Dialog, a Sheet and the palette sit on one backdrop.\n *\n *   Surfaces   panelSurface, menuSurface, modalSurface, backdropClass, modalViewportClass\n *   Rows       itemClass, withHighlight, ItemHighlight, HighlightGroup, ItemParts, ItemCheck,\n *              ItemChevron, Shortcut, separatorClass, groupClass, groupLabelClass, flashThenClose\n *   Motion     OVERLAY_ROLES (anchored), MODAL, SHEET, TOAST, modalMotion(), bump()\n *\n * Base UI owns behaviour; `_primitives/floating` owns how anchored popups appear. See README.md.\n */\n\nconst ms = (s: number) => Math.round(s * 1000);\n\n/* ---------------------------------------------------------------- Motion ---------- */\n\nexport type OverlayRole = FloatingMotion & { side: FloatingSide; sideOffset: number };\n\n/**\n * One motion table for every anchored layer. Spread an entry into `FloatingLayer`'s `motion`\n * and pass its `side` / `sideOffset`. Values are the floating layer's, tuned per job:\n * menus are a touch quicker than popovers, context menus open with no animation (rule 84).\n */\nexport const OVERLAY_ROLES = {\n  tooltip: FLOATING_ROLES.tooltip,\n  popover: FLOATING_ROLES.popover,\n  card: FLOATING_ROLES.card,\n  menu: { side: \"bottom\", sideOffset: 4, distance: 4, scale: 0.97, fade: ms(duration.fast), exit: ms(duration.instant), honourInstant: false },\n  /** Opens beside its row, overlapping the parent by the menu's 4px padding so rows line up. */\n  submenu: { side: \"inline-end\", sideOffset: 0, distance: 4, scale: 0.97, fade: ms(duration.fast), exit: ms(duration.instant), honourInstant: false },\n  listbox: { side: \"bottom\", sideOffset: 4, distance: 4, scale: 0.97, fade: ms(duration.fast), exit: ms(duration.instant), honourInstant: false },\n  /** No enter (rule 84: a right-click is high-frequency); a 100ms fade on the way out. */\n  context: { side: \"bottom\", sideOffset: 0, distance: 0, scale: 1, fade: 0, exit: ms(duration.instant), honourInstant: false },\n} as const satisfies Record<string, OverlayRole>;\n\n/** Dialogs: rise 8px at 0.96 on the smooth spring; exit 4px at 0.98 in 160ms; parents step back per nested level. */\nexport const MODAL = { y: 8, scale: 0.96, fade: ms(duration.standard), exit: 160, nestedY: 16, nestedScale: 0.05, bump: 0.015 } as const;\n\n/** Sheets: the sheet duration on the enter curve; a swipe release scales its exit by the swipe's strength. */\nexport const SHEET = { duration: ms(duration.sheet), exit: 375, indentScale: 0.97, indentY: 8 } as const;\n\n/** Toasts: rise 12px at 0.97; stacked toasts peek 10px and shrink 5% each; 8px apart when expanded. */\nexport const TOAST = { y: 12, scale: 0.97, fade: ms(duration.standard), exit: ms(duration.fast), peek: 10, shrink: 0.05, gap: 8 } as const;\n\n/*\n * The modal classes read short working variables (`--em-*`) so `data-instant` and the reduced\n * variant can override them; an inline style would beat a class.\n */\nconst MODAL_MOTION = [\n  // Literal classes (Tailwind reads source text); they match MODAL above.\n  \"[--em-y:8px] [--em-s:0.96] [--em-in:var(--eased-duration-spring-smooth)] [--em-fade:200ms] [--em-out:160ms]\",\n  \"[transition-property:opacity,translate,scale] [transition-duration:var(--em-fade),var(--em-in),var(--em-in)] [transition-timing-function:var(--eased-ease-enter),var(--eased-spring-smooth),var(--eased-spring-smooth)]\",\n  \"data-starting-style:opacity-0 data-starting-style:[translate:0_var(--em-y)] data-starting-style:[scale:var(--em-s)]\",\n  \"data-ending-style:opacity-0 data-ending-style:[translate:0_calc(var(--em-y)*0.5)] data-ending-style:[scale:calc(0.5_+_var(--em-s)*0.5)] data-ending-style:[transition-duration:var(--em-out)] data-ending-style:[transition-timing-function:var(--eased-ease-standard)]\",\n  // A parent under a nested dialog steps back: up 16px and 5% smaller per level, dimmed by a scrim layer.\n  \"data-nested-dialog-open:[translate:0_calc(var(--nested-dialogs)*-16px)] data-nested-dialog-open:[scale:calc(1_-_var(--nested-dialogs)*0.05)]\",\n  \"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-scrim after:opacity-0 after:[transition:opacity_var(--eased-duration-standard)_var(--eased-ease-standard)] data-nested-dialog-open:after:opacity-50\",\n  // Opened from a shortcut: no enter animation (rule 84). The exit still plays.\n  \"data-instant:[--em-in:0ms] data-instant:[--em-fade:0ms]\",\n  \"reduced:[--em-y:0px] reduced:[--em-s:1] reduced:[--em-in:0ms] reduced:[--em-fade:var(--eased-duration-instant)] reduced:[--em-out:var(--eased-duration-instant)] reduced:data-nested-dialog-open:[translate:0] reduced:data-nested-dialog-open:[scale:1]\",\n].join(\" \");\n\n/** Transition classes for a modal popup (Dialog, AlertDialog, the palette). Needs `position: relative` or fixed. */\nexport function modalMotion() {\n  return MODAL_MOTION;\n}\n\n/**\n * The alert dialog's \"no\": a 1.5% scale bump when someone clicks outside a dialog that won't\n * close that way. Runs on `transform`, so it composes with the `scale` transition. Reduced\n * motion skips it.\n */\nexport function bump(el: HTMLElement | null) {\n  if (!el || typeof el.animate !== \"function\") return;\n  if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n  el.animate([{ transform: \"scale(1)\" }, { transform: `scale(${1 + MODAL.bump})` }, { transform: \"scale(1)\" }], {\n    duration: 260,\n    easing: \"cubic-bezier(0.22, 1, 0.36, 1)\",\n  });\n}\n\n/* -------------------------------------------------------------- Surfaces ---------- */\n\n/** The anchored panel: raised surface, `border-line`, `shadow-floating`, 16px corners. Add padding and `--pad`. */\nexport const panelSurface = `${floatingSurface} rounded-panel [--radius-outer:var(--eased-radius-panel)]`;\n\n/**\n * A menu or listbox popup. 4px padding (rows get an 11px concentric radius) and a three-track\n * grid, icon · label · trailing, that every row subgrids into, so labels share one x and\n * shortcuts, checks and chevrons share one column whether or not a row has them (R101–R103).\n */\nexport const menuSurface = `${panelSurface} grid grid-cols-[auto_minmax(0,1fr)_auto] content-start min-w-48 max-w-(--available-width) max-h-(--available-height) overflow-y-auto overscroll-contain p-1 scroll-py-1 [--pad:4px]`;\n\n/** Dialogs and the palette: raised, `border-line`, `shadow-overlay`, 24px corners, 24px padding var. */\nexport const modalSurface =\n  \"relative flex flex-col border border-line bg-surface-raised text-ui text-text shadow-overlay outline-none rounded-surface [--radius-outer:var(--eased-radius-surface)] [--pad:24px] max-sm:[--pad:20px]\";\n\n/** The scrim under modals (R34): `bg-scrim`, a 7px backdrop blur, fading in 200ms and out 150ms. */\nexport const backdropClass =\n  \"fixed inset-0 z-50 min-h-dvh bg-scrim backdrop-blur-[7px] [transition:opacity_var(--eased-duration-standard)_var(--eased-ease-enter)] data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:[transition-duration:var(--eased-duration-fast)] data-ending-style:[transition-timing-function:var(--eased-ease-standard)] reduced:[transition-duration:var(--eased-duration-instant)] supports-[-webkit-touch-callout:none]:absolute\";\n\n/** A fixed viewport that centres its popup with a 16px gutter and scrolls when the popup is taller than the screen. */\nexport const modalViewportClass = \"fixed inset-0 z-50 flex overflow-y-auto overscroll-contain p-4 [&>*]:m-auto\";\n\n/** A full-bleed rule between groups inside `menuSurface`. */\nexport const separatorClass = \"col-span-full -mx-(--pad) my-1 h-px bg-line-subtle\";\n\n/** A group of rows (`Menu.Group`, `Menu.RadioGroup`, `Select.Group`) that stays on the menu's tracks. */\nexport const groupClass = \"col-span-full grid grid-cols-subgrid\";\n\n/** A group label on the rows' leading edge, where icons and checks start. */\nexport const groupLabelClass = \"col-span-full flex h-7 items-center px-2 text-ui-xs text-text-muted select-none\";\n\n/* ------------------------------------------------------------------ Rows ---------- */\n\nexport type ItemClassOptions = {\n  /** `\"danger\"`: red label and icon, a red-tinted highlight. For destructive rows. */\n  tone?: \"default\" | \"danger\";\n  /**\n   * True when the row renders `withHighlight`, so the gliding fill replaces the static one.\n   * Default false: the row fills in place on `data-highlighted` (lists that filter or scroll).\n   */\n  glide?: boolean;\n};\n\nconst ROW =\n  \"relative isolate col-span-full grid grid-cols-subgrid items-center min-h-8 pointer-coarse:min-h-10 px-2 py-1.5 radius-nested text-ui-sm text-text outline-none select-none cursor-default scroll-my-1 [transition:color_var(--eased-duration-fast)_var(--eased-ease-standard)] data-disabled:text-text-disabled data-disabled:cursor-not-allowed data-popup-open:bg-surface-hover\";\n\n/** Classes for any Base UI row: Menu, Select, Combobox and Autocomplete items, checkbox and radio items, submenu triggers. */\nexport function itemClass({ tone = \"default\", glide = false }: ItemClassOptions = {}) {\n  const fill = glide ? \"\" : tone === \"danger\" ? \"data-highlighted:bg-danger-subtle\" : \"data-highlighted:bg-surface-hover\";\n  return `${ROW} ${tone === \"danger\" ? \"text-danger-text\" : \"\"} ${fill}`;\n}\n\n/** Scopes one popup's gliding highlight. Wrap each popup's rows; a submenu gets its own group. */\nexport function HighlightGroup({ children }: { children: ReactNode }) {\n  return <IndicatorGroup>{children}</IndicatorGroup>;\n}\n\n/** The fill that glides between rows on the snappy spring (an Eased `Indicator`). Render it inside the highlighted row. */\nexport function ItemHighlight({ tone = \"default\" }: { tone?: \"default\" | \"danger\" }) {\n  return (\n    <Indicator\n      name=\"overlay-highlight\"\n      className={`inset-0 -z-1 radius-nested in-data-flash:opacity-0 ${tone === \"danger\" ? \"bg-danger-subtle\" : \"bg-surface-hover\"}`}\n    />\n  );\n}\n\ntype RenderProps = HTMLAttributes<HTMLElement> & { ref?: Ref<HTMLElement> };\n\n/**\n * A Base UI `render` function that adds the gliding highlight to a row while it is highlighted:\n * `<Menu.Item render={withHighlight} className={itemClass({ glide: true })}>`.\n * The row's `data-tone=\"danger\"` (set by `itemClass` users) tints it.\n */\nexport function withHighlight(props: RenderProps, state: { highlighted: boolean }): ReactElement {\n  const { children, ...rest } = props;\n  const tone = (rest as Record<string, unknown>)[\"data-tone\"] === \"danger\" ? \"danger\" : \"default\";\n  return (\n    <div {...(rest as HTMLAttributes<HTMLDivElement>)} ref={props.ref as Ref<HTMLDivElement>}>\n      {state.highlighted && <ItemHighlight tone={tone} />}\n      {children}\n    </div>\n  );\n}\n\nexport type ItemPartsProps = {\n  /** 16px icon in the leading track. Muted at rest, full colour while the row is highlighted. */\n  icon?: ReactNode;\n  /** The label. One line, truncates. */\n  children: ReactNode;\n  /** A second, muted line under the label. The icon stays centred on the first line (R107). */\n  description?: ReactNode;\n  /** Shortcut glyphs, e.g. `\"⌘⇧D\"`, in the trailing column. */\n  shortcut?: string;\n  /** Anything else for the trailing column: a chevron, a count, a badge. */\n  trailing?: ReactNode;\n};\n\n/**\n * A row's content laid on the menu's three tracks. Use inside any row with `itemClass`.\n * Rows with no icon leave the icon track to the rows that have one, so every label starts on\n * one x; trailing content right-aligns in one column.\n */\nexport function ItemParts({ icon, children, description, shortcut, trailing }: ItemPartsProps) {\n  // One line: centre on the row. Two lines: pin to the first line (R107).\n  const anchor = description !== undefined ? \"self-start\" : \"self-center\";\n  return (\n    <>\n      {icon !== undefined && (\n        <span\n          aria-hidden\n          className={`col-start-1 me-2 flex h-[1lh] items-center ${anchor} text-text-muted in-data-highlighted:text-current in-data-disabled:text-text-disabled in-data-[tone=danger]:text-current [&_svg]:size-4 [&_svg]:shrink-0`}\n        >\n          {icon}\n        </span>\n      )}\n      <span className=\"col-start-2 flex min-w-0 flex-col\">\n        <span className=\"truncate\">{children}</span>\n        {description !== undefined && <span className=\"truncate text-ui-xs text-text-muted\">{description}</span>}\n      </span>\n      {(shortcut !== undefined || trailing !== undefined) && (\n        <span className={`col-start-3 ms-6 flex h-[1lh] items-center justify-end gap-2 ${anchor}`}>\n          {shortcut !== undefined && <Shortcut keys={shortcut} />}\n          {trailing}\n        </span>\n      )}\n    </>\n  );\n}\n\nconst SPOKEN: Record<string, string> = {\n  \"⌘\": \"Command\",\n  \"⇧\": \"Shift\",\n  \"⌥\": \"Option\",\n  \"⌃\": \"Control\",\n  \"⌫\": \"Delete\",\n  \"⌦\": \"Forward Delete\",\n  \"↵\": \"Return\",\n  \"⏎\": \"Return\",\n  \"⎋\": \"Escape\",\n  \"⇥\": \"Tab\",\n  \"↑\": \"Up\",\n  \"↓\": \"Down\",\n  \"←\": \"Left\",\n  \"→\": \"Right\",\n};\n\n/** Shortcut glyphs in the muted trailing style, read by name (\"Command Shift D\"). */\nexport function Shortcut({ keys, className = \"\" }: { keys: string; className?: string }) {\n  const glyphs = [...keys];\n  return (\n    <span\n      aria-label={glyphs.map((g) => SPOKEN[g] ?? g).join(\" \")}\n      className={`inline-flex items-center gap-px text-ui-xs text-text-muted tabular-nums in-data-disabled:text-text-disabled ${className}`}\n    >\n      {glyphs.map((g, i) => (\n        <span key={i} aria-hidden className=\"min-w-[1ch] text-center\">\n          {g}\n        </span>\n      ))}\n    </span>\n  );\n}\n\n/**\n * A 16px check for checkbox, radio and selected rows. It reads the row's `data-checked` or\n * `data-selected`: the stroke draws in on the snappy spring when the row becomes checked and\n * clears in 100ms when it doesn't. Put it in `ItemParts`' `icon` so it holds the icon track.\n */\nexport function ItemCheck({ className = \"\" }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className={`size-4 shrink-0 text-text ${className}`}>\n      <path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        pathLength={1}\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        className=\"[stroke-dasharray:1] [stroke-dashoffset:1] [transition:stroke-dashoffset_var(--eased-duration-instant)_var(--eased-ease-standard)] in-data-checked:[stroke-dashoffset:0] in-data-selected:[stroke-dashoffset:0] in-data-checked:[transition:stroke-dashoffset_var(--eased-duration-spring-snappy)_var(--eased-spring-snappy)] in-data-selected:[transition:stroke-dashoffset_var(--eased-duration-spring-snappy)_var(--eased-spring-snappy)] reduced:[transition:none]!\"\n      />\n    </svg>\n  );\n}\n\n/** The submenu chevron, nudged 1px toward its direction (R57). Mirrors in RTL. */\nexport function ItemChevron() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className=\"size-4 shrink-0 text-text-muted rtl:-scale-x-100 in-data-highlighted:text-current\">\n      <path d=\"m6.5 4 4 4-4 4\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\n/**\n * macOS's selection blink: the row's highlight goes out for 40ms and back for 40ms, then\n * `close` runs. With reduced motion, `close` runs at once. Returns a cancel function.\n */\nexport function flashThenClose(row: HTMLElement | null, close: () => void, reduced: boolean) {\n  if (!row || reduced) {\n    close();\n    return () => {};\n  }\n  row.setAttribute(\"data-flash\", \"\");\n  const a = window.setTimeout(() => row.removeAttribute(\"data-flash\"), 40);\n  const b = window.setTimeout(close, 80);\n  return () => {\n    window.clearTimeout(a);\n    window.clearTimeout(b);\n    row.removeAttribute(\"data-flash\");\n  };\n}\n\n/** Inline custom properties for a layer that wants the role values as CSS variables. */\nexport function overlayVars(role: OverlayRole): CSSProperties {\n  return {\n    \"--eased-float-distance\": `${role.distance}px`,\n    \"--eased-float-scale\": role.scale,\n    \"--eased-float-fade\": `${role.fade}ms`,\n    \"--eased-float-exit\": `${role.exit}ms`,\n  } as CSSProperties;\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/overlay-core/overlay-core.tsx"
    }
  ],
  "type": "registry:ui"
}