{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"soft-snap","type":"registry:component","title":"Soft Snap Cards","description":"Full-width cards that settle into frame without hijacking your scroll.","dependencies":[],"categories":["motion","scroll","editorial","cards","css"],"files":[{"path":"components/lab/soft-snap/soft-snap.tsx","target":"components/lab/soft-snap/soft-snap.tsx","type":"registry:component","content":"\"use client\";\n\nimport {\n  useEffect,\n  useRef,\n  type CSSProperties,\n  type ReactNode,\n} from \"react\";\nimport \"./soft-snap.css\";\n\nexport type SoftSnapMode = \"off\" | \"proximity\" | \"mandatory\" | \"assist\";\nexport type SoftSnapAlign = \"start\" | \"center\" | \"nearest\";\nexport type SoftSnapGate = \"fine\" | \"always\";\n\ntype SoftSnapProps = {\n  /** Settle strength. `assist` is the only JS mode — a cancelable nudge. */\n  mode?: SoftSnapMode;\n  align?: SoftSnapAlign;\n  /** Space between items, in pixels. */\n  gap?: number;\n  /**\n   * `fine` (default) applies settling only on wide, fine-pointer,\n   * no-preference screens. `always` applies it wherever it is supported.\n   */\n  gate?: SoftSnapGate;\n  /** Distance in pixels from the lane's top that a settled item keeps. */\n  offset?: number;\n  className?: string;\n  children: ReactNode;\n};\n\nconst ASSIST_TOLERANCE = 8;\nconst ASSIST_MAX_DISTANCE = 0.45;\nconst ASSIST_MIN_VISIBLE = 0.55;\nconst ASSIST_IDLE_MS = 150;\n\n/**\n * SOFT SNAP — large cards settle into frame without scrolljacking.\n *\n * CSS scroll-snap proximity does the work: when a gesture ends near an\n * item the browser settles it, and when the reader keeps scrolling the\n * lane never pulls them back. `assist` mode adds a small, cancelable\n * nudge for browsers or layouts where proximity alone is too weak —\n * it waits for scrolling to stop, only fires when an item is already\n * mostly in view and close to its resting place, and is cancelled by\n * any new input.\n */\nexport function SoftSnap({\n  mode = \"proximity\",\n  align = \"start\",\n  gap = 24,\n  gate = \"fine\",\n  offset = 0,\n  className,\n  children,\n}: SoftSnapProps) {\n  const ref = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    if (mode !== \"assist\") return;\n    const lane = ref.current;\n    if (!lane) return;\n\n    const fine = window.matchMedia(\"(pointer: fine)\");\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    if (!fine.matches || reduced.matches) return;\n\n    let timer: number | undefined;\n    let cancelled = false;\n\n    const settle = () => {\n      if (cancelled || !lane.isConnected) return;\n      const box = lane.getBoundingClientRect();\n      if (box.height === 0) return;\n      const items = Array.from(\n        lane.querySelectorAll<HTMLElement>(\"[data-snap-item]\"),\n      );\n      if (items.length === 0) return;\n\n      let best: HTMLElement | null = null;\n      let bestDistance = Number.POSITIVE_INFINITY;\n      for (const item of items) {\n        const rect = item.getBoundingClientRect();\n        const distance = align === \"center\"\n          ? Math.abs(\n              rect.top + rect.height / 2 - (box.top + box.height / 2),\n            )\n          : Math.abs(rect.top - box.top - offset);\n        if (distance < bestDistance) {\n          bestDistance = distance;\n          best = item;\n        }\n      }\n      if (!best) return;\n\n      const rect = best.getBoundingClientRect();\n      const visible =\n        Math.min(rect.bottom, box.bottom) - Math.max(rect.top, box.top);\n      const ratio = visible / Math.min(rect.height, box.height);\n      if (ratio < ASSIST_MIN_VISIBLE) return;\n      if (bestDistance > box.height * ASSIST_MAX_DISTANCE) return;\n      if (bestDistance <= ASSIST_TOLERANCE) return;\n\n      const target =\n        align === \"center\"\n          ? rect.top - box.top - (box.height - rect.height) / 2\n          : rect.top - box.top - offset;\n      lane.scrollTo({ top: lane.scrollTop + target, behavior: \"smooth\" });\n    };\n\n    const onScroll = () => {\n      if (timer) window.clearTimeout(timer);\n      timer = window.setTimeout(settle, ASSIST_IDLE_MS);\n    };\n    const onInput = () => {\n      if (timer) window.clearTimeout(timer);\n    };\n\n    lane.addEventListener(\"scroll\", onScroll, { passive: true });\n    lane.addEventListener(\"wheel\", onInput, { passive: true });\n    lane.addEventListener(\"touchstart\", onInput, { passive: true });\n    lane.addEventListener(\"pointerdown\", onInput);\n\n    return () => {\n      cancelled = true;\n      if (timer) window.clearTimeout(timer);\n      lane.removeEventListener(\"scroll\", onScroll);\n      lane.removeEventListener(\"wheel\", onInput);\n      lane.removeEventListener(\"touchstart\", onInput);\n      lane.removeEventListener(\"pointerdown\", onInput);\n    };\n  }, [mode, align, offset]);\n\n  return (\n    <div\n      ref={ref}\n      className={`lab-soft-snap${className ? ` ${className}` : \"\"}`}\n      data-mode={mode}\n      data-align={align}\n      data-gate={gate}\n      style={{ \"--snap-gap\": `${gap}px`, \"--snap-offset\": `${offset}px` } as CSSProperties}\n    >\n      {children}\n    </div>\n  );\n}\n\n/**\n * SOFT SNAP ITEM — one settle target. Items keep their own layout;\n * only their snapping alignment is owned by the lane.\n */\nexport function SoftSnapItem({\n  className,\n  children,\n}: {\n  className?: string;\n  children: ReactNode;\n}) {\n  return (\n    <div\n      className={`lab-soft-snap-item${className ? ` ${className}` : \"\"}`}\n      data-snap-item\n    >\n      {children}\n    </div>\n  );\n}\n"},{"path":"components/lab/soft-snap/soft-snap.css","target":"components/lab/soft-snap/soft-snap.css","type":"registry:file","content":"/* ============================================================\n   SOFT SNAP — self-contained styles.\n   Every token is scoped with a fallback, so the component works in\n   any codebase without the Lab's stylesheet.\n   ============================================================ */\n\n.lab-soft-snap {\n  --soft-snap-gap: var(--snap-gap, 24px);\n  --soft-snap-offset: var(--snap-offset, 0px);\n  height: 100%;\n  overflow-y: auto;\n  overscroll-behavior: contain;\n  scroll-padding-top: var(--soft-snap-offset);\n  -webkit-overflow-scrolling: touch;\n}\n\n.lab-soft-snap-item {\n  display: flow-root;\n}\n\n.lab-soft-snap-item + .lab-soft-snap-item {\n  margin-top: var(--soft-snap-gap);\n}\n\n.lab-soft-snap-item:last-child {\n  margin-bottom: var(--soft-snap-gap);\n}\n\n/* Alignment per item — always applied, in every mode. */\n.lab-soft-snap[data-align=\"start\"] .lab-soft-snap-item {\n  scroll-snap-align: start;\n  scroll-margin-top: var(--soft-snap-offset);\n}\n\n.lab-soft-snap[data-align=\"center\"] .lab-soft-snap-item {\n  scroll-snap-align: center;\n}\n\n.lab-soft-snap[data-align=\"nearest\"] .lab-soft-snap-item {\n  scroll-snap-align: nearest;\n}\n\n/* Always-on gating for hosts that want the behaviour everywhere. */\n.lab-soft-snap[data-gate=\"always\"][data-mode=\"proximity\"] {\n  scroll-snap-type: y proximity;\n}\n\n.lab-soft-snap[data-gate=\"always\"][data-mode=\"mandatory\"] {\n  scroll-snap-type: y mandatory;\n}\n\n.lab-soft-snap[data-mode=\"off\"] {\n  scroll-snap-type: none;\n}\n\n/*\n * The gated default: settling is an enhancement for wide screens with\n * a fine pointer and no reduced-motion preference. Touch keeps its\n * native momentum scrolling untouched.\n */\n@media (min-width: 64rem) and (pointer: fine) and (prefers-reduced-motion: no-preference) {\n  .lab-soft-snap[data-gate=\"fine\"][data-mode=\"proximity\"] {\n    scroll-snap-type: y proximity;\n  }\n\n  .lab-soft-snap[data-gate=\"fine\"][data-mode=\"mandatory\"] {\n    scroll-snap-type: y mandatory;\n  }\n}\n\n/* Assist mode never uses CSS snapping — the JS nudge is the settle. */\n.lab-soft-snap[data-mode=\"assist\"] {\n  scroll-snap-type: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .lab-soft-snap {\n    scroll-behavior: auto;\n  }\n}\n"}]}