{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "glide",
  "type": "registry:lib",
  "title": "Glide",
  "description": "The highlight that glides from row to row in the Menu and the Command menu.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/lib/glide.tsx",
      "type": "registry:lib",
      "content": "\"use client\";\n// @litt/glide 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Glide: the one highlight that travels between the rows of a list.\n *\n * Used by the menu and the command menu (litt.md Q16: one gliding highlight\n * instead of a per-row fill).\n *\n * - One highlight element per list, absolutely positioned behind the rows. It\n *   moves with Motion `y` (a transform) on the responsive tier. Rows sit edge\n *   to edge, so the list never goes dark between them.\n * - It fades in AT the row when it first appears (no travel), and fades out in\n *   place when the pointer leaves (useExit). Touch never glides: a tap snaps.\n * - Mouse: the row nearest the pointer wins, even over a gap or a separator.\n *   Keyboard: it follows the highlighted item.\n * - Keyboard-driven highlights also show an inset 1px focus ring for 3:1\n *   visibility. A pointer-driven highlight shows no ring.\n * - Forced colors: the highlight is Highlight, the system's current-item fill, so\n *   the row on it sets forced-color-adjust: none (the browser's backplate would\n *   paint Canvas under its words) and HighlightText on itself and its children. A\n *   list whose highlight is hover only, beside a selection that is Highlight\n *   already, passes forcedHighlight: false.\n * - Fun mode (motion/fun.tsx, a trial, off unless a <FunMode> is on): the highlight\n *   stretches toward the row it is going to. Its leading edge goes on fun mode's glide\n *   spring and its trailing edge follows on the slower swap spring, so it reaches out,\n *   then gathers itself on the row (a scale along y, from its top). It rests exactly\n *   where it does without fun mode. Reduced motion turns it off.\n *\n * Usage\n *   const glide = useGlide();\n *   <div ref={glide.ref} {...glide.props} className=\"relative\">\n *     {glide.highlight}\n *     <div {...GLIDE_ROW} className=\"relative\">…</div>   // every row, in DOM order\n *   </div>\n *   glide.setActive(index | null, \"keyboard\" | \"pointer\" | \"touch\")\n *\n * Rows are the elements marked data-glide-row, and an index is a row's position\n * in DOM order. A row marked data-disabled or aria-disabled is skipped by the pointer.\n */\nimport { useCallback, useMemo, useRef, type KeyboardEvent, type PointerEvent } from \"react\";\nimport { animate, motion, useMotionValue, useTransform, type AnimationPlaybackControls } from \"motion/react\";\nimport { useExit, useFun, useTransition } from \"./motion\";\n\nexport type GlideSource = \"pointer\" | \"keyboard\" | \"touch\";\n\nexport type GlideOptions = {\n  /**\n   * Called with the row nearest a pointer. The default highlights it directly.\n   * The command menu overrides this to keep its own state as the source of truth.\n   */\n  onPointerRow?: (index: number, row: HTMLElement, type: \"mouse\" | \"touch\") => void;\n  /**\n   * Radix menus keep their highlight on DOM focus. With this on, a mouse over a gap\n   * focuses the nearest row, and the focus event drives the highlight (see focusRow).\n   */\n  focusRows?: boolean;\n  /** Keep the highlight when the pointer leaves. A function can decide (a submenu is open). */\n  keepOnLeave?: boolean | (() => boolean);\n  /** Scroll the row into view on keyboard moves (a list with a max height). */\n  scroll?: boolean;\n  /** Under forced colors, fill the highlight with Highlight (the default). Off where Highlight already means selected. */\n  forcedHighlight?: boolean;\n};\n\n/** Spread onto every row. */\nexport const GLIDE_ROW = { \"data-glide-row\": \"\" } as const;\n\nconst ROW = \"[data-glide-row]\";\nconst isDisabled = (el: HTMLElement) => el.hasAttribute(\"data-disabled\") || el.getAttribute(\"aria-disabled\") === \"true\";\n\nexport function useGlide(options: GlideOptions = {}) {\n  const enter = useTransition(\"responsive\");\n  const exit = useExit(\"responsive\");\n  const fun = useFun();\n  const latest = useRef({ enter, exit, options, fun });\n  latest.current = { enter, exit, options, fun };\n\n  const y = useMotionValue(0);\n  /* Fun mode: the bottom edge moves on its own spring, and the highlight is drawn from y to it. */\n  const bottom = useMotionValue(0);\n  const height = useRef(1);\n  const scaleY = useTransform(() => (bottom.get() - y.get()) / height.current);\n  const opacity = useMotionValue(0);\n  const list = useRef<HTMLElement | null>(null);\n  const hl = useRef<HTMLDivElement | null>(null);\n  const observer = useRef<ResizeObserver | null>(null);\n  const shown = useRef(false);\n  const current = useRef<number | null>(null);\n  const modality = useRef<\"pointer\" | \"keyboard\">(\"keyboard\");\n  const inside = useRef(false);\n  const lastPoint = useRef({ x: -1, y: -1 });\n  const runs = useRef<{ y?: AnimationPlaybackControls; b?: AnimationPlaybackControls; o?: AnimationPlaybackControls }>({});\n\n  const rows = useCallback(() => Array.from(list.current?.querySelectorAll<HTMLElement>(ROW) ?? []), []);\n\n  const place = useCallback(\n    (row: HTMLElement, jump: boolean) => {\n      const el = hl.current;\n      if (!el) return;\n      /* Geometry is static (every row is the same size); only y moves. */\n      el.style.left = `${row.offsetLeft}px`;\n      el.style.width = `${row.offsetWidth}px`;\n      el.style.height = `${row.offsetHeight}px`;\n      runs.current.y?.stop();\n      runs.current.b?.stop();\n      const f = latest.current.fun;\n      if (f) {\n        /* Fun mode: the edge on the side it is going leads on the glide spring, the other trails on the swap spring. */\n        const top = row.offsetTop;\n        const end = top + row.offsetHeight;\n        height.current = row.offsetHeight || 1;\n        if (jump) {\n          y.jump(top);\n          bottom.jump(end);\n          return;\n        }\n        /* Fun mode turned on while a highlight showed: the bottom edge starts where the highlight is. */\n        if (Math.abs(bottom.get() - y.get() - row.offsetHeight) > 1 && !runs.current.b) bottom.jump(y.get() + row.offsetHeight);\n        const down = top > y.get();\n        runs.current.y = animate(y, top, down ? f.swap : f.glide);\n        runs.current.b = animate(bottom, end, down ? f.glide : f.swap);\n        return;\n      }\n      if (jump) y.jump(row.offsetTop);\n      else runs.current.y = animate(y, row.offsetTop, latest.current.enter);\n    },\n    [y, bottom],\n  );\n\n  const hide = useCallback(() => {\n    if (!shown.current) return;\n    shown.current = false;\n    current.current = null;\n    runs.current.o?.stop();\n    runs.current.o = animate(opacity, 0, latest.current.exit);\n  }, [opacity]);\n\n  const setActive = useCallback(\n    (index: number | null, source: GlideSource, opts?: { jump?: boolean }) => {\n      if (index === null) return hide();\n      const row = rows()[index];\n      const el = hl.current;\n      if (!row || !el) return;\n      el.dataset.ring = String(source === \"keyboard\");\n      const first = !shown.current;\n      if (first || index !== current.current || opts?.jump) place(row, first || !!opts?.jump || source === \"touch\");\n      if (first) {\n        shown.current = true;\n        runs.current.o?.stop();\n        runs.current.o = animate(opacity, 1, latest.current.enter);\n      }\n      current.current = index;\n      if (latest.current.options.scroll && source === \"keyboard\") {\n        if (index === 0 && list.current) list.current.scrollTop = 0;\n        else row.scrollIntoView({ block: \"nearest\" });\n      }\n    },\n    [hide, opacity, place, rows],\n  );\n\n  /** Re-measure after the list changed shape. Snaps, never glides. */\n  const refresh = useCallback(() => {\n    if (!shown.current || current.current === null) return;\n    const row = rows()[current.current];\n    if (row) place(row, true);\n  }, [place, rows]);\n\n  const ref = useCallback(\n    (el: HTMLElement | null) => {\n      observer.current?.disconnect();\n      list.current = el;\n      if (el && typeof ResizeObserver !== \"undefined\") {\n        observer.current = new ResizeObserver(() => refresh());\n        observer.current.observe(el);\n      }\n    },\n    [refresh],\n  );\n\n  /** For Radix items: call from onFocus. The source follows the last input. */\n  const focusRow = useCallback(\n    (el: HTMLElement) => {\n      const i = rows().indexOf(el);\n      if (i >= 0) setActive(i, modality.current);\n    },\n    [rows, setActive],\n  );\n\n  /** Drop or restore the ring without moving: focus has gone into a submenu, so the ring belongs there. */\n  const setRing = useCallback((on: boolean) => {\n    if (hl.current) hl.current.dataset.ring = String(on);\n  }, []);\n\n  /** A submenu closed: if the pointer is elsewhere, let go of the parent's highlight. */\n  const release = useCallback(() => {\n    if (modality.current === \"pointer\" && !inside.current) hide();\n  }, [hide]);\n\n  const nearest = useCallback(\n    (e: PointerEvent, type: \"mouse\" | \"touch\") => {\n      const root = list.current;\n      /* Events from a portaled submenu bubble through React; only handle our own DOM. */\n      if (!root || !root.contains(e.target as Node)) return;\n      const all = rows();\n      let best = -1;\n      let bestD = Infinity;\n      all.forEach((r, i) => {\n        if (isDisabled(r)) return;\n        const b = r.getBoundingClientRect();\n        const d = e.clientY < b.top ? b.top - e.clientY : e.clientY > b.bottom ? e.clientY - b.bottom : 0;\n        if (d < bestD) {\n          bestD = d;\n          best = i;\n        }\n      });\n      if (best < 0) return;\n      const row = all[best];\n      const o = latest.current.options;\n      if (o.onPointerRow) return o.onPointerRow(best, row, type);\n      if (type === \"mouse\" && o.focusRows) {\n        /* Over a row, Radix focuses it itself (and honours its submenu grace area). */\n        if ((e.target as HTMLElement).closest(ROW)) return;\n        const keep = o.keepOnLeave;\n        if (typeof keep === \"function\" ? keep() : keep) return;\n        if (document.activeElement !== row) row.focus({ preventScroll: true });\n        return;\n      }\n      setActive(best, type === \"mouse\" ? \"pointer\" : \"touch\");\n    },\n    [rows, setActive],\n  );\n\n  const props = useMemo(\n    () => ({\n      onPointerEnter: () => {\n        inside.current = true;\n      },\n      onPointerLeave: (e: PointerEvent) => {\n        inside.current = false;\n        if (e.pointerType === \"touch\") return;\n        const keep = latest.current.options.keepOnLeave;\n        if (typeof keep === \"function\" ? keep() : keep) return;\n        hide();\n      },\n      onPointerMoveCapture: (e: PointerEvent) => {\n        if (e.pointerType === \"touch\") return;\n        /* A list that scrolls under a resting mouse fires fake moves; ignore them. */\n        const p = lastPoint.current;\n        if (p.x === e.clientX && p.y === e.clientY) return;\n        lastPoint.current = { x: e.clientX, y: e.clientY };\n        modality.current = \"pointer\";\n        nearest(e, \"mouse\");\n      },\n      onPointerDownCapture: (e: PointerEvent) => {\n        if (e.pointerType === \"touch\") nearest(e, \"touch\");\n      },\n      onKeyDownCapture: (_e: KeyboardEvent) => {\n        modality.current = \"keyboard\";\n      },\n    }),\n    [hide, nearest],\n  );\n\n  const highlight = (\n    <motion.div\n      ref={hl}\n      aria-hidden=\"true\"\n      data-ring=\"false\"\n      className={\n        \"pointer-events-none absolute top-0 left-0 rounded-litt-control in-[[data-material=flat]]:rounded-[calc(var(--litt-radius-surface)-4px)] bg-[color-mix(in_oklab,var(--litt-fg)_7%,transparent)] data-[ring=true]:shadow-[inset_0_0_0_1px_var(--litt-focus)]\" +\n        (options.forcedHighlight === false ? \"\" : \" forced-colors:bg-[Highlight] forced-colors:[forced-color-adjust:none]\")\n      }\n      style={fun ? { y, opacity, scaleY, originY: 0 } : { y, opacity }}\n    />\n  );\n\n  return { ref, props, highlight, setActive, hide, refresh, focusRow, release, setRing };\n}\n\nexport type Glide = ReturnType<typeof useGlide>;\n",
      "target": "lib/litt/glide.tsx"
    }
  ],
  "docs": "Installed in lib/litt/glide.tsx. Menu and Command menu use it for you.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
