{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "swipe-row-detent",
  "title": "Swipe Row Detent",
  "description": "A swipeable list row that clicks through machined detent stops (archive, then flag) and strains against a hard stop before overtravel arms delete, with a 3-second undo bar before it commits.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/swipe-row-detent/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// DetentSwipe — a swipeable list row with machined detent stops, not a free\n// drag-to-reveal. Dragging left steps through two magnetic detents (archive,\n// then flag) — crossing each zone's threshold \"clicks\" the row to that\n// detent's exact position with a fast settle, rather than following the\n// finger continuously. Past the second detent a hard stop resists further\n// travel (diminishing-returns curve) while the row edge compresses; enough\n// overtravel ARMS delete. Releasing while armed opens a 3-second undo bar\n// (a burn-down bar) before the delete actually commits.\n//\n// Actions are `visibility: hidden` (not display:none, not unmounted) until\n// their detent is reached — this correctly removes them from hit-testing\n// AND the accessibility tree while resting, with zero risk of an invisible\n// button intercepting a click. Enter (keyboard) invokes the handler\n// directly, so keyboard activation never depends on the button itself\n// being focusable at rest.\n// ---------------------------------------------------------------------------\n\nexport interface DetentSwipeProps {\n  /** primary row text */\n  title: string;\n  /** secondary row text under the title */\n  subtitle?: string;\n  /** called when the archive action is revealed and confirmed */\n  onArchive?: () => void;\n  /** called when the flag action is revealed and confirmed */\n  onFlag?: () => void;\n  /** called when the delete action is revealed and confirmed */\n  onDelete?: () => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst ARCHIVE_W = 76;\nconst FLAG_W = 76;\nconst D1 = ARCHIVE_W;\nconst D2 = ARCHIVE_W + FLAG_W;\nconst OVERTRAVEL_MAX = 96;\nconst RESIST_K = 70;\nconst ARM_RAW_DELTA = D2 + 64;\nconst SETTLE_MS = 140;\nconst EASE_SETTLE = \"cubic-bezier(0.2, 0.7, 0.2, 1)\";\nconst UNDO_MS = 3000;\nconst DETENTS: [number, number, number] = [0, D1, D2];\n\nfunction clamp(n: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, n));\n}\n\nexport function DetentSwipe({ title, subtitle, onArchive, onFlag, onDelete, className = \"\" }: DetentSwipeProps) {\n  const contentRef = useRef<HTMLDivElement | null>(null);\n  const burnRef = useRef<HTMLDivElement | null>(null);\n  const draggingRef = useRef(false);\n  const baseOffsetRef = useRef(0);\n  const downXRef = useRef(0);\n  const engagedRef = useRef<0 | 1 | 2>(0);\n  const armedRef = useRef(false);\n  const reducedRef = useRef(false);\n  const undoTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\n  const [engagedIndex, setEngagedIndex] = useState<0 | 1 | 2>(0);\n  const [armed, setArmed] = useState(false);\n  const [pendingDelete, setPendingDelete] = useState(false);\n  const [announce, setAnnounce] = useState(\"\");\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    reducedRef.current = mq.matches;\n    const onChange = () => {\n      reducedRef.current = mq.matches;\n    };\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n\n  useEffect(\n    () => () => {\n      if (undoTimerRef.current) clearTimeout(undoTimerRef.current);\n    },\n    []\n  );\n\n  const setX = useCallback((px: number, animate: boolean) => {\n    const el = contentRef.current;\n    if (!el) return;\n    el.style.transition = animate && !reducedRef.current ? `transform ${SETTLE_MS}ms ${EASE_SETTLE}` : \"none\";\n    el.style.setProperty(\"--dtx-x\", `${-px}px`);\n  }, []);\n\n  const goRest = useCallback(\n    (animate = true) => {\n      engagedRef.current = 0;\n      armedRef.current = false;\n      setEngagedIndex(0);\n      setArmed(false);\n      setPendingDelete(false);\n      setX(0, animate);\n    },\n    [setX]\n  );\n\n  const startUndo = useCallback(() => {\n    setPendingDelete(true);\n    setAnnounce(`Delete armed for ${title}. Undo within 3 seconds.`);\n    const burn = burnRef.current;\n    if (burn) {\n      burn.style.transition = \"none\";\n      burn.style.width = \"100%\";\n      requestAnimationFrame(() => {\n        burn.style.transition = reducedRef.current ? \"none\" : `width ${UNDO_MS}ms linear`;\n        burn.style.width = \"0%\";\n      });\n    }\n    undoTimerRef.current = setTimeout(() => {\n      onDelete?.();\n      goRest(false);\n      setAnnounce(`${title} deleted.`);\n    }, UNDO_MS);\n  }, [goRest, onDelete, title]);\n\n  const cancelUndo = useCallback(() => {\n    if (undoTimerRef.current) clearTimeout(undoTimerRef.current);\n    setAnnounce(`Delete cancelled for ${title}.`);\n    goRest(true);\n  }, [goRest, title]);\n\n  const onPointerDown = useCallback(\n    (e: ReactPointerEvent<HTMLDivElement>) => {\n      if (pendingDelete) return;\n      const el = contentRef.current;\n      if (!el) return;\n      el.setPointerCapture(e.pointerId);\n      draggingRef.current = true;\n      baseOffsetRef.current = DETENTS[engagedRef.current];\n      downXRef.current = e.clientX;\n    },\n    [pendingDelete]\n  );\n\n  const onPointerMove = useCallback(\n    (e: ReactPointerEvent<HTMLDivElement>) => {\n      if (!draggingRef.current) return;\n      const raw = clamp(baseOffsetRef.current + (downXRef.current - e.clientX), 0, Infinity);\n\n      if (raw <= D2) {\n        const zone: 0 | 1 | 2 = raw < D1 * 0.5 ? 0 : raw < D1 + (D2 - D1) * 0.5 ? 1 : 2;\n        if (zone !== engagedRef.current) {\n          engagedRef.current = zone;\n          setEngagedIndex(zone);\n          setX(DETENTS[zone], true);\n          if (zone > 0) setAnnounce(zone === 1 ? \"Archive revealed.\" : \"Flag revealed.\");\n        }\n        if (armedRef.current) {\n          armedRef.current = false;\n          setArmed(false);\n        }\n      } else {\n        const extra = raw - D2;\n        const resisted = OVERTRAVEL_MAX * (1 - 1 / (1 + extra / RESIST_K));\n        const nowArmed = raw >= ARM_RAW_DELTA;\n        setX(D2 + resisted - (nowArmed ? 2 : 0), false);\n        if (nowArmed !== armedRef.current) {\n          armedRef.current = nowArmed;\n          setArmed(nowArmed);\n          if (nowArmed) setAnnounce(\"Delete armed. Release to confirm.\");\n        }\n        if (engagedRef.current !== 2) {\n          engagedRef.current = 2;\n          setEngagedIndex(2);\n        }\n      }\n    },\n    [setX]\n  );\n\n  const onPointerUp = useCallback(() => {\n    if (!draggingRef.current) return;\n    draggingRef.current = false;\n    if (armedRef.current) {\n      setX(D2 + OVERTRAVEL_MAX - 2, true);\n      startUndo();\n      return;\n    }\n    setX(DETENTS[engagedRef.current], true);\n  }, [setX, startUndo]);\n\n  const activate = useCallback(() => {\n    if (engagedRef.current === 1) {\n      onArchive?.();\n      goRest(true);\n    } else if (engagedRef.current === 2) {\n      onFlag?.();\n      goRest(true);\n    }\n  }, [goRest, onArchive, onFlag]);\n\n  const onKeyDown = useCallback(\n    (e: ReactKeyboardEvent<HTMLDivElement>) => {\n      if (pendingDelete) return;\n      if (e.key === \"ArrowRight\") {\n        e.preventDefault();\n        const next = clamp(engagedRef.current + 1, 0, 2) as 0 | 1 | 2;\n        engagedRef.current = next;\n        setEngagedIndex(next);\n        setX(DETENTS[next], true);\n      } else if (e.key === \"ArrowLeft\") {\n        e.preventDefault();\n        const next = clamp(engagedRef.current - 1, 0, 2) as 0 | 1 | 2;\n        engagedRef.current = next;\n        setEngagedIndex(next);\n        setX(DETENTS[next], true);\n      } else if (e.key === \"Enter\") {\n        e.preventDefault();\n        activate();\n      } else if (e.key === \"Escape\" && engagedRef.current !== 0) {\n        e.preventDefault();\n        goRest(true);\n      }\n    },\n    [activate, goRest, pendingDelete]\n  );\n\n  const restable = engagedIndex === 0 && !armed && !pendingDelete;\n  // Rendered from state, not hardcoded — a re-render (e.g. from setEngagedIndex)\n  // replaces the whole inline `style` object, which would otherwise stomp the\n  // ref-driven --dtx-x value from mid-drag imperative writes back to 0. Keying\n  // it off `engagedIndex` means any such reset lands on the CORRECT resting\n  // detent instead of always snapping back to rest.\n  const contentStyle: CSSProperties & Record<string, string> = {\n    transform: \"translate(var(--dtx-x, 0px), var(--dtx-y, 0px))\",\n    \"--dtx-x\": `${-DETENTS[engagedIndex]}px`,\n  };\n\n  return (\n    <div\n      tabIndex={0}\n      role=\"group\"\n      aria-label={`${title}${subtitle ? `, ${subtitle}` : \"\"}. Use arrow keys to reveal Archive and Flag, Enter to activate, Escape to close.`}\n      onKeyDown={onKeyDown}\n      // overflow-hidden lives on the INNER clip wrapper below, never on this\n      // focusable element — an element clips its own focus-visible outline\n      // when overflow isn't `visible` (confirmed empirically: the ring never\n      // painted in a screenshot despite computed style reporting it), so the\n      // outlined element and the overflow-clipping element must be different\n      // nodes.\n      className={`ns-dtx-wrap relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent ${className}`}\n    >\n      <style>{CSS}</style>\n      <span role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {announce}\n      </span>\n\n      <div className=\"relative overflow-hidden\">\n      {/* DOM/flex order is Delete, Flag, Archive (left-to-right) even though\n          detent order is archive-first — the strip is right-anchored, so the\n          LAST flex child sits at the row's true right edge and is uncovered\n          first as content shifts left. Archive must be that last child to be\n          the first thing revealed; Delete goes first so it only clears once\n          content has shifted past both other actions' widths (overtravel). */}\n      <div className=\"absolute inset-y-0 right-0 flex\">\n        <button\n          type=\"button\"\n          onClick={() => {\n            if (armed) startUndo();\n          }}\n          style={{ visibility: armed ? \"visible\" : \"hidden\" }}\n          className=\"ns-dtx-action ns-dtx-delete flex w-[96px] items-center justify-center border-l font-mono text-[11px] uppercase tracking-wide focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ns-accent\"\n        >\n          Delete\n        </button>\n        <button\n          type=\"button\"\n          onClick={() => {\n            onFlag?.();\n            goRest(true);\n          }}\n          style={{ visibility: engagedIndex >= 2 ? \"visible\" : \"hidden\" }}\n          className={`ns-dtx-action flex w-[76px] items-center justify-center border-l font-mono text-[11px] uppercase tracking-wide text-foreground hover:bg-border/40 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ns-accent ${\n            engagedIndex >= 2 ? \"border-l-foreground bg-border/20\" : \"border-l-border\"\n          }`}\n        >\n          Flag\n        </button>\n        <button\n          type=\"button\"\n          onClick={() => {\n            onArchive?.();\n            goRest(true);\n          }}\n          style={{ visibility: engagedIndex >= 1 ? \"visible\" : \"hidden\" }}\n          className={`ns-dtx-action flex w-[76px] items-center justify-center border-l font-mono text-[11px] uppercase tracking-wide text-foreground hover:bg-border/40 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ns-accent ${\n            engagedIndex >= 1 ? \"border-l-foreground bg-border/20\" : \"border-l-border\"\n          }`}\n        >\n          Archive\n        </button>\n      </div>\n\n      <div\n        ref={contentRef}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerUp}\n        style={contentStyle}\n        className={`ns-dtx-content relative flex items-center gap-3 border-b border-border bg-background px-4 py-4 ${\n          restable ? \"ns-dtx-restable\" : \"\"\n        }`}\n      >\n        <span aria-hidden=\"true\" className=\"ns-dtx-grip absolute left-1.5 flex flex-col gap-1 opacity-0\">\n          <span className=\"h-1 w-1 rounded-full bg-ns-muted\" />\n          <span className=\"h-1 w-1 rounded-full bg-ns-muted\" />\n          <span className=\"h-1 w-1 rounded-full bg-ns-muted\" />\n        </span>\n        <span aria-hidden=\"true\" className=\"h-9 w-9 shrink-0 rounded-full border border-border\" />\n        <span className=\"min-w-0 flex-1\">\n          <span className=\"block truncate text-sm text-foreground\">{title}</span>\n          {subtitle && <span className=\"block truncate text-xs text-ns-muted\">{subtitle}</span>}\n        </span>\n      </div>\n\n      {pendingDelete && (\n        <div className=\"absolute inset-0 flex items-center justify-between border-b border-border bg-background px-4\">\n          <span className=\"font-mono text-xs text-ns-muted\">{title} deleted</span>\n          <button\n            type=\"button\"\n            onClick={cancelUndo}\n            className=\"font-mono text-xs text-foreground underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n          >\n            Undo\n          </button>\n          <div ref={burnRef} aria-hidden=\"true\" className=\"ns-dtx-burn absolute inset-x-0 bottom-0 h-px\" />\n        </div>\n      )}\n      </div>\n    </div>\n  );\n}\n\nconst CSS = `\n.ns-dtx-restable:hover{ --dtx-y: -1px; }\n.ns-dtx-restable:hover .ns-dtx-grip{ opacity: 1; }\n.ns-dtx-content{ cursor: grab; touch-action: pan-y; }\n.ns-dtx-content:active{ cursor: grabbing; }\n.ns-dtx-grip{ transition: opacity 150ms ease-out; }\n.ns-dtx-action{ transition: background-color 150ms ease-out, border-color 150ms ease-out; }\n.ns-dtx-delete{ color: var(--error); border-left-color: var(--error); }\n.ns-dtx-delete:hover{ background: color-mix(in srgb, var(--error) 14%, transparent); }\n.ns-dtx-burn{ background: var(--error); }\n@media (prefers-reduced-motion: reduce){\n  .ns-dtx-grip, .ns-dtx-action { transition: none; }\n}\n`;\n\nexport default DetentSwipe;\n",
      "type": "registry:ui",
      "target": "components/ui/swipe-row-detent.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "error": "#ea001d"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "error": "#ff6369"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "swipe",
      "list",
      "row",
      "gesture",
      "drag",
      "undo",
      "accessibility"
    ],
    "instruction": "Build a swipeable list row, not a generic free-drag swipe-to-reveal — the identity here is MECHANICAL: the row clicks through fixed detent positions as you drag, rather than following the pointer 1:1. Layout: a relative, overflow-hidden row wrapper; an absolutely-positioned actions strip pinned to the right edge (Archive ~76px wide, Flag ~76px wide, Delete ~96px wide, each a real `<button>`); and a foreground content layer (avatar circle + title/subtitle text) that is the actual pointer-drag target, translated left over the actions strip via a CSS custom property (`--dtx-x`, applied once as `transform: translate(var(--dtx-x,0px), var(--dtx-y,0px))` so drag writes only ever touch that one custom property directly on the ref, never touching `transform` as a whole string — keeps a separate hover-lift `--dtx-y` from fighting the drag offset).\n\nDetents: D1 = 76px (archive revealed), D2 = 152px (archive+flag revealed, the hard stop). While dragging, track the raw pointer delta (initial-down-x minus current-x, offset by whatever detent it was already resting at if re-grabbed) and classify it into a zone (0 / 1 / 2) by which detent's midpoint threshold it has crossed. On a ZONE CHANGE ONLY, snap the content's offset to that detent's exact px value with a short (~140ms) eased settle transition — between zone changes the row does NOT continuously follow the finger, it holds at its current detent. This discrete jump-between-detents behavior, not smooth 1:1 dragging, is what makes it read as 'machined' rather than a rubber-band swipe. A newly-engaged action button's left border highlights (--border -> --foreground) as its detent engages, standing in for the brief's 'faint tick mark.'\n\nOvertravel: once the raw delta exceeds D2, switch to continuous tracking with a diminishing-returns resistance curve (`resisted = MAX * (1 - 1/(1 + extra/K))`, extra = raw-D2) so further dragging yields less and less additional reveal — this IS a direct per-pointermove ref/style write (the hot path), unlike the discrete detent jumps above. Once raw delta passes a further threshold beyond D2, arm delete: the Delete button becomes visible/hittable (`visibility: visible`), its border and text render in `var(--error)` (there is no Tailwind `error` utility registered in this repo's theme — reference the color via arbitrary-value classes or a small scoped CSS rule, never invent a hardcoded hex), and the row edge visibly compresses by ~2px (fold that into the same offset calculation rather than a separate transform) to read as strain. Releasing while armed does NOT commit immediately: it opens a 3-second undo bar (a full-width bar that burns down via a `width` transition from 100% to 0%, direct ref write, not React state) with a real 'Undo' button; if untouched, the timer fires the delete callback and resets the row. Clicking Undo cancels the timer and returns to rest. Releasing NOT armed always settles to the currently-engaged detent (0, 1, or 2) — there is no separate spring-back-to-zero-on-release behavior for non-overtravel drags, since the row is designed to rest open at a detent until an action is taken.\n\nActions are real `<button>` elements that exist in the DOM at all times but are `visibility: hidden` (never `display:none`, never conditionally unmounted) until their detent is reached — this is deliberate: `visibility:hidden` removes an element from both hit-testing and the accessibility tree exactly like being genuinely absent, with none of the risk of an invisible-but-still-hittable element silently intercepting clicks or confusing a generic 'first interactive element' test. Because of this, keyboard activation must NOT depend on focusing/clicking the (possibly hidden) button DOM node — Enter should call the archive/flag handler directly based on which detent is currently engaged.\n\nKeyboard: the row itself is a focusable (`tabIndex=0`, `role=\"group\"`, a descriptive `aria-label`) container. ArrowRight/ArrowLeft step the engaged detent index up/down through 0/1/2 with the same settle transition (keyboard never arms delete — that is pointer/touch overtravel only, by design). Enter activates whichever action is currently engaged (archive at index 1, flag at index 2; no-op at rest). Escape returns to rest from any engaged detent. Pointer AND touch both drive the same pointer-event handlers (use Pointer Events, not separate mouse/touch listeners, and `touch-action: pan-y` so vertical list scrolling still works while horizontal drag is captured).\n\nHover (rest state only, i.e. detent 0 and not armed/pending-delete): the row lifts 1px (via the `--dtx-y` custom property flipped by a plain CSS `:hover` rule scoped to a 'restable' class, never JS pointer-tracking) and small grip dots fade in near the left edge (opacity via the same scoped `:hover` rule) — purely CSS, no extra JS needed for either cue. A `cursor: grab` / `:active { cursor: grabbing }` pair on the content layer reinforces the drag affordance.\n\nReduced motion: skip the ~140ms detent-settle transition and the undo-bar's burn-down transition entirely — every position change (detent snap, overtravel offset, undo-bar reset) applies with `transition: none`, landing instantly at its target, while remaining fully functional (the undo bar still holds for the same 3 seconds, it just doesn't animate the width).\n\nAccessibility: a dedicated sr-only `role=status aria-live=polite aria-atomic=true` span announces each meaningful transition ('Archive revealed.', 'Flag revealed.', 'Delete armed. Release to confirm.', 'Delete cancelled for {title}.', '{title} deleted.') separately from any button's own label. No dependencies, no gradient backgrounds, no color outside the repo's CSS variables (including the semantic `--error` token for the destructive state, used via its custom property, not a hardcoded hex)."
  },
  "type": "registry:ui"
}