{"slug":"swipeable-list","name":"Swipeable List","description":"Mobile-style list rows that swipe left or right to reveal contextual action buttons.","category":"blocks","source_url":"https://beui.saura3h.xyz/r/swipeable-list/raw","detail_url":"https://beui.saura3h.xyz/r/swipeable-list","raw_url":"https://beui.saura3h.xyz/r/swipeable-list/raw","page_url":"https://beui.saura3h.xyz/components/blocks/swipeable-list","dependencies":["clsx","lucide-react","motion","react","tailwind-merge"],"internal":["@/components/motion/swipeable-list","@/lib/utils"],"files":[{"path":"components/motion/swipeable-list.tsx","type":"component","content":"\"use client\";\n\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  type PanInfo,\n} from \"motion/react\";\nimport {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type SwipeSide = \"left\" | \"right\";\n\nexport type SwipeableListValue = {\n  id: string;\n  side: SwipeSide;\n};\n\nexport type SwipeActionTone =\n  | \"neutral\"\n  | \"primary\"\n  | \"success\"\n  | \"warning\"\n  | \"danger\";\n\nexport type SwipeAction = {\n  id: string;\n  label: ReactNode;\n  icon: ReactNode;\n  tone?: SwipeActionTone;\n  disabled?: boolean;\n  onClick?: (item: SwipeableListItem) => void;\n};\n\nexport type SwipeableListItem = {\n  id: string;\n  title?: ReactNode;\n  description?: ReactNode;\n  meta?: ReactNode;\n  leading?: ReactNode;\n  content?: ReactNode;\n  leftActions?: SwipeAction[];\n  rightActions?: SwipeAction[];\n  disabled?: boolean;\n};\n\nexport type SwipeableListClassNames = {\n  root?: string;\n  item?: string;\n  rail?: string;\n  action?: string;\n  surface?: string;\n  leading?: string;\n  content?: string;\n  title?: string;\n  description?: string;\n  meta?: string;\n};\n\nexport interface SwipeableListProps {\n  items: SwipeableListItem[];\n  value?: SwipeableListValue | null;\n  defaultValue?: SwipeableListValue | null;\n  onValueChange?: (value: SwipeableListValue | null) => void;\n  onAction?: (payload: {\n    item: SwipeableListItem;\n    action: SwipeAction;\n    side: SwipeSide;\n  }) => void;\n  actionWidth?: number;\n  revealThreshold?: number;\n  closeOnAction?: boolean;\n  className?: string;\n  classNames?: SwipeableListClassNames;\n  renderItem?: (item: SwipeableListItem) => ReactNode;\n}\n\n// Distance-based release spring keeps short rebounds and full reveals feeling\n// equally direct, closer to native mobile list interactions.\nconst ROW_SETTLE = {\n  type: \"spring\",\n  stiffness: 560,\n  damping: 48,\n  mass: 0.82,\n  restDelta: 0.5,\n  restSpeed: 8,\n} as const;\nconst OPEN_DISTANCE_RATIO = 0.46;\nconst CLOSE_DISTANCE_RATIO = 0.72;\nconst OPEN_VELOCITY = 720;\nconst CLOSE_VELOCITY = 320;\nconst FLING_DISTANCE = 14;\nconst RELEASE_VELOCITY_LIMIT = 1500;\n\nconst ACTION_TONE_CLASS: Record<SwipeActionTone, string> = {\n  neutral: \"text-muted-foreground group-hover:text-foreground\",\n  primary: \"text-foreground\",\n  success: \"text-emerald-600 dark:text-emerald-400\",\n  warning: \"text-amber-600 dark:text-amber-400\",\n  danger: \"text-destructive\",\n};\n\nfunction useControllableSwipeValue({\n  value,\n  defaultValue,\n  onValueChange,\n}: {\n  value?: SwipeableListValue | null;\n  defaultValue?: SwipeableListValue | null;\n  onValueChange?: (value: SwipeableListValue | null) => void;\n}) {\n  const [internalValue, setInternalValue] = useState(defaultValue ?? null);\n  const isControlled = value !== undefined;\n  const currentValue = value ?? internalValue;\n\n  const setValue = useCallback(\n    (next: SwipeableListValue | null) => {\n      if (!isControlled) {\n        setInternalValue(next);\n      }\n\n      onValueChange?.(next);\n    },\n    [isControlled, onValueChange],\n  );\n\n  return [currentValue, setValue] as const;\n}\n\nfunction isActionableSide(value: number, sideWidth: number) {\n  return sideWidth > 0 && Math.abs(value) > 0;\n}\n\nfunction clampReleaseVelocity(velocity: number) {\n  return Math.max(\n    -RELEASE_VELOCITY_LIMIT,\n    Math.min(RELEASE_VELOCITY_LIMIT, velocity),\n  );\n}\n\nfunction SwipeActionButton({\n  action,\n  actionWidth,\n  side,\n  focusable,\n  onAction,\n  className,\n}: {\n  action: SwipeAction;\n  actionWidth: number;\n  side: SwipeSide;\n  focusable: boolean;\n  onAction: (action: SwipeAction, side: SwipeSide) => void;\n  className?: string;\n}) {\n  return (\n    <button\n      type=\"button\"\n      disabled={action.disabled}\n      tabIndex={focusable ? 0 : -1}\n      aria-label={typeof action.label === \"string\" ? action.label : undefined}\n      onClick={() => onAction(action, side)}\n      className={cn(\n        \"group flex h-full shrink-0 items-center justify-center outline-none\",\n        \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        className,\n      )}\n      style={{ width: actionWidth }}\n    >\n      <span\n        className={cn(\n          \"grid h-9 w-9 place-items-center rounded-full transition-[background-color,color,transform] duration-150 group-hover:bg-background group-active:scale-95\",\n          ACTION_TONE_CLASS[action.tone ?? \"neutral\"],\n        )}\n      >\n        {action.icon}\n      </span>\n      <span className=\"sr-only\">{action.label}</span>\n    </button>\n  );\n}\n\nfunction SwipeableListRow({\n  item,\n  actionWidth,\n  revealThreshold,\n  openValue,\n  setOpenValue,\n  closeOnAction,\n  onAction,\n  classNames,\n  renderItem,\n}: {\n  item: SwipeableListItem;\n  actionWidth: number;\n  revealThreshold: number;\n  openValue: SwipeableListValue | null;\n  setOpenValue: (value: SwipeableListValue | null) => void;\n  closeOnAction: boolean;\n  onAction?: SwipeableListProps[\"onAction\"];\n  classNames?: SwipeableListClassNames;\n  renderItem?: (item: SwipeableListItem) => ReactNode;\n}) {\n  const reduce = useReducedMotion();\n  const x = useMotionValue(0);\n  const animationRef = useRef<{ stop: () => void } | null>(null);\n  const commandedTargetRef = useRef(0);\n  const leftActions = item.leftActions ?? [];\n  const rightActions = item.rightActions ?? [];\n  const leftWidth = leftActions.length * actionWidth;\n  const rightWidth = rightActions.length * actionWidth;\n  const openSide = openValue?.id === item.id ? openValue.side : null;\n  const targetX =\n    openSide === \"left\" ? leftWidth : openSide === \"right\" ? -rightWidth : 0;\n\n  const settleX = useCallback(\n    (nextX: number, velocity = 0) => {\n      commandedTargetRef.current = nextX;\n      animationRef.current?.stop();\n\n      if (reduce) {\n        x.set(nextX);\n        return;\n      }\n\n      animationRef.current = animate(x, nextX, {\n        ...ROW_SETTLE,\n        velocity: clampReleaseVelocity(velocity),\n        onComplete: () => x.set(nextX),\n      });\n    },\n    [reduce, x],\n  );\n\n  useEffect(() => {\n    return () => animationRef.current?.stop();\n  }, []);\n\n  useEffect(() => {\n    if (commandedTargetRef.current === targetX) {\n      return;\n    }\n\n    settleX(targetX);\n  }, [settleX, targetX]);\n\n  const getTargetX = useCallback(\n    (side: SwipeSide | null) =>\n      side === \"left\" ? leftWidth : side === \"right\" ? -rightWidth : 0,\n    [leftWidth, rightWidth],\n  );\n\n  const snapTo = useCallback(\n    (side: SwipeSide | null, velocity = 0) => {\n      setOpenValue(side ? { id: item.id, side } : null);\n      settleX(getTargetX(side), velocity);\n    },\n    [getTargetX, item.id, setOpenValue, settleX],\n  );\n\n  const onDragStart = useCallback(() => {\n    animationRef.current?.stop();\n\n    if (openValue && openValue.id !== item.id) {\n      setOpenValue(null);\n    }\n  }, [item.id, openValue, setOpenValue]);\n\n  const onDragEnd = useCallback(\n    (_: PointerEvent, info: PanInfo) => {\n      const velocity = info.velocity.x;\n      const latest = x.get();\n      const leftOpenThreshold = Math.max(\n        revealThreshold,\n        leftWidth * OPEN_DISTANCE_RATIO,\n      );\n      const rightOpenThreshold = Math.max(\n        revealThreshold,\n        rightWidth * OPEN_DISTANCE_RATIO,\n      );\n\n      if (openSide === \"left\") {\n        if (\n          latest < leftWidth * CLOSE_DISTANCE_RATIO ||\n          velocity < -CLOSE_VELOCITY\n        ) {\n          snapTo(null, velocity);\n          return;\n        }\n\n        snapTo(\"left\", velocity);\n        return;\n      }\n\n      if (openSide === \"right\") {\n        if (\n          Math.abs(latest) < rightWidth * CLOSE_DISTANCE_RATIO ||\n          velocity > CLOSE_VELOCITY\n        ) {\n          snapTo(null, velocity);\n          return;\n        }\n\n        snapTo(\"right\", velocity);\n        return;\n      }\n\n      if (\n        isActionableSide(latest, leftWidth) &&\n        (latest > leftOpenThreshold ||\n          (velocity > OPEN_VELOCITY && latest > FLING_DISTANCE))\n      ) {\n        snapTo(\"left\", velocity);\n        return;\n      }\n\n      if (\n        isActionableSide(latest, rightWidth) &&\n        (latest < -rightOpenThreshold ||\n          (velocity < -OPEN_VELOCITY && latest < -FLING_DISTANCE))\n      ) {\n        snapTo(\"right\", velocity);\n        return;\n      }\n\n      snapTo(null, velocity);\n    },\n    [\n      leftWidth,\n      openSide,\n      revealThreshold,\n      rightWidth,\n      snapTo,\n      x,\n    ],\n  );\n\n  const handleAction = useCallback(\n    (action: SwipeAction, side: SwipeSide) => {\n      action.onClick?.(item);\n      onAction?.({ item, action, side });\n\n      if (closeOnAction) {\n        snapTo(null);\n      }\n    },\n    [closeOnAction, item, onAction, snapTo],\n  );\n\n  const defaultContent = (\n    <div className=\"flex min-w-0 items-center gap-3\">\n      {item.leading ? (\n        <div className={cn(\"shrink-0\", classNames?.leading)}>\n          {item.leading}\n        </div>\n      ) : null}\n      <div className={cn(\"min-w-0 flex-1\", classNames?.content)}>\n        {item.title ? (\n          <div\n            className={cn(\n              \"truncate text-sm font-medium text-foreground\",\n              classNames?.title,\n            )}\n          >\n            {item.title}\n          </div>\n        ) : null}\n        {item.description ? (\n          <div\n            className={cn(\n              \"mt-0.5 truncate text-xs text-muted-foreground\",\n              classNames?.description,\n            )}\n          >\n            {item.description}\n          </div>\n        ) : null}\n      </div>\n      {item.meta ? (\n        <div\n          className={cn(\n            \"shrink-0 text-xs font-medium text-muted-foreground\",\n            classNames?.meta,\n          )}\n        >\n          {item.meta}\n        </div>\n      ) : null}\n    </div>\n  );\n\n  return (\n    <div\n      className={cn(\n        \"relative isolate overflow-hidden rounded-2xl bg-muted\",\n        item.disabled && \"opacity-60\",\n        classNames?.item,\n      )}\n    >\n      <div\n        aria-hidden={!openSide}\n        className={cn(\n          \"absolute inset-0 z-0 flex overflow-hidden rounded-2xl\",\n          classNames?.rail,\n        )}\n      >\n        <div className=\"flex h-full overflow-hidden rounded-l-2xl\">\n          {leftActions.map((action) => (\n            <SwipeActionButton\n              key={action.id}\n              action={action}\n              actionWidth={actionWidth}\n              className={classNames?.action}\n              focusable={openSide === \"left\"}\n              onAction={handleAction}\n              side=\"left\"\n            />\n          ))}\n        </div>\n        <div className=\"ml-auto flex h-full overflow-hidden rounded-r-2xl\">\n          {rightActions.map((action) => (\n            <SwipeActionButton\n              key={action.id}\n              action={action}\n              actionWidth={actionWidth}\n              className={classNames?.action}\n              focusable={openSide === \"right\"}\n              onAction={handleAction}\n              side=\"right\"\n            />\n          ))}\n        </div>\n      </div>\n\n      <motion.div\n        drag={item.disabled ? false : \"x\"}\n        dragConstraints={{ left: -rightWidth, right: leftWidth }}\n        dragElastic={0.04}\n        dragMomentum={false}\n        onDragStart={onDragStart}\n        onDragEnd={onDragEnd}\n        style={{ x }}\n        className={cn(\n          \"relative z-10 min-h-[72px] cursor-grab touch-pan-y select-none rounded-2xl border border-border bg-card px-4 py-3 shadow-sm active:cursor-grabbing\",\n          classNames?.surface,\n        )}\n      >\n        {renderItem ? renderItem(item) : item.content ?? defaultContent}\n      </motion.div>\n    </div>\n  );\n}\n\nexport function SwipeableList({\n  items,\n  value,\n  defaultValue = null,\n  onValueChange,\n  onAction,\n  actionWidth = 56,\n  revealThreshold = 34,\n  closeOnAction = true,\n  className,\n  classNames,\n  renderItem,\n}: SwipeableListProps) {\n  const [openValue, setOpenValue] = useControllableSwipeValue({\n    value,\n    defaultValue,\n    onValueChange,\n  });\n\n  return (\n    <div className={cn(\"flex w-full flex-col gap-2\", className, classNames?.root)}>\n      {items.map((item) => (\n        <SwipeableListRow\n          key={item.id}\n          item={item}\n          actionWidth={actionWidth}\n          revealThreshold={revealThreshold}\n          openValue={openValue}\n          setOpenValue={setOpenValue}\n          closeOnAction={closeOnAction}\n          onAction={onAction}\n          classNames={classNames}\n          renderItem={renderItem}\n        />\n      ))}\n    </div>\n  );\n}\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/previews/blocks/swipeable-list.preview.tsx","type":"preview","content":"\"use client\";\n\nimport {\n  Check,\n  Clock3,\n  FileText,\n  Flag,\n  Mail,\n  Pin,\n  RotateCcw,\n  Trash2,\n  UserRound,\n} from \"lucide-react\";\nimport { useState } from \"react\";\nimport {\n  SwipeableList,\n  type SwipeAction,\n  type SwipeableListItem,\n} from \"@/components/motion/swipeable-list\";\n\nconst leftActions: SwipeAction[] = [\n  {\n    id: \"done\",\n    label: \"Done\",\n    icon: <Check className=\"h-4 w-4\" />,\n    tone: \"success\",\n  },\n  {\n    id: \"pin\",\n    label: \"Pin\",\n    icon: <Pin className=\"h-4 w-4\" />,\n    tone: \"primary\",\n  },\n];\n\nconst rightActions: SwipeAction[] = [\n  {\n    id: \"later\",\n    label: \"Later\",\n    icon: <Clock3 className=\"h-4 w-4\" />,\n    tone: \"warning\",\n  },\n  {\n    id: \"trash\",\n    label: \"Trash\",\n    icon: <Trash2 className=\"h-4 w-4\" />,\n    tone: \"danger\",\n  },\n];\n\nconst initialItems: SwipeableListItem[] = [\n  {\n    id: \"brief\",\n    title: \"Launch brief\",\n    description: \"Finalize the announcement copy\",\n    meta: \"9:41\",\n    leading: (\n      <div className=\"grid h-10 w-10 place-items-center rounded-xl border border-border bg-background text-muted-foreground\">\n        <FileText className=\"h-4 w-4\" />\n      </div>\n    ),\n    leftActions,\n    rightActions,\n  },\n  {\n    id: \"feedback\",\n    title: \"Client feedback\",\n    description: \"Three comments need a response\",\n    meta: \"11:08\",\n    leading: (\n      <div className=\"grid h-10 w-10 place-items-center rounded-xl border border-border bg-background text-muted-foreground\">\n        <Mail className=\"h-4 w-4\" />\n      </div>\n    ),\n    leftActions,\n    rightActions,\n  },\n  {\n    id: \"review\",\n    title: \"Design review\",\n    description: \"Check spacing before handoff\",\n    meta: \"13:20\",\n    leading: (\n      <div className=\"grid h-10 w-10 place-items-center rounded-xl border border-border bg-background text-muted-foreground\">\n        <UserRound className=\"h-4 w-4\" />\n      </div>\n    ),\n    leftActions,\n    rightActions,\n  },\n  {\n    id: \"incident\",\n    title: \"Flagged run\",\n    description: \"Retry queue has one failed job\",\n    meta: \"Now\",\n    leading: (\n      <div className=\"grid h-10 w-10 place-items-center rounded-xl border border-border bg-background text-muted-foreground\">\n        <Flag className=\"h-4 w-4\" />\n      </div>\n    ),\n    leftActions,\n    rightActions,\n  },\n];\n\nexport function SwipeableListPreview() {\n  const [items, setItems] = useState(initialItems);\n  const [lastAction, setLastAction] = useState(\"Ready\");\n\n  return (\n    <div className=\"flex min-h-96 w-full items-center justify-center\">\n      <div className=\"w-full max-w-sm rounded-[2rem] border border-border bg-background p-3 shadow-2xl\">\n        <div className=\"mb-3 flex items-center justify-between px-1\">\n          <div>\n            <p className=\"text-sm font-semibold text-foreground\">Priority queue</p>\n            <p className=\"text-xs text-muted-foreground\">{lastAction}</p>\n          </div>\n          <button\n            type=\"button\"\n            onClick={() => {\n              setItems(initialItems);\n              setLastAction(\"Queue restored\");\n            }}\n            className=\"inline-flex h-8 items-center gap-1.5 rounded-full border border-border px-3 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground\"\n          >\n            <RotateCcw className=\"h-3.5 w-3.5\" />\n            Reset\n          </button>\n        </div>\n\n        <SwipeableList\n          items={items}\n          onAction={({ item, action }) => {\n            setLastAction(`${action.label} · ${item.title}`);\n\n            if (action.id === \"trash\") {\n              setItems((current) => current.filter((entry) => entry.id !== item.id));\n            }\n          }}\n        />\n\n        <div className=\"mt-3 flex items-center justify-between px-1 text-[11px] font-medium text-muted-foreground\">\n          <span>{items.length} open</span>\n          <span>Today</span>\n        </div>\n      </div>\n    </div>\n  );\n}\n"}]}