{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "author": "DesignX ", "name": "swipeable-list", "type": "registry:component", "title": "Swipeable List", "description": "List rows with swipe actions.", "dependencies": [ "motion@^14.0.0" ], "registryDependencies": [ "@dx/lib-touch", "@dx/utils" ], "files": [ { "path": "components/motion/swipeable-list.tsx", "type": "registry:component", "target": "components/motion/swipeable-list.tsx", "content": "import {\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 { TOUCH_GESTURE_CONTENT_CLASS } from \"@/lib/touch\";\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 = {\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 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 \n {action.icon}\n \n {action.label}\n \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
\n {item.leading ? (\n
\n {item.leading}\n
\n ) : null}\n
\n {item.title ? (\n \n {item.title}\n
\n ) : null}\n {item.description ? (\n \n {item.description}\n
\n ) : null}\n \n {item.meta ? (\n \n {item.meta}\n \n ) : null}\n \n );\n\n return (\n \n \n
\n {leftActions.map((action) => (\n \n ))}\n
\n
\n {rightActions.map((action) => (\n \n ))}\n
\n \n\n \n {renderItem ? renderItem(item) : item.content ?? defaultContent}\n \n \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
\n {items.map((item) => (\n \n ))}\n
\n );\n}\n" } ], "categories": [ "motion-kit" ], "meta": { "group": "motion", "docs": "https://dxuireact.com/docs/components/swipeable-list" } }