Framer X » Animation » Example Animations » 35. Swipe to Delete

35. Swipe to Delete

Open the Framer Motion version in CodeSandbox

Framer Motion

This is quite a complicated example. A lot is going on here.

A few details in the Item() component:

  • When the dragging stops (onDragEnd()), the handleDragEnd() function looks at the current offset or velocity.
  • When one of them is high enough, the useAnimation() animation will slide the item offscreen,
  • …and then the onDelete() on the parent component gets called.
  • There’s a springy positionTransition, so when the parent component moves the items, this is how they will animate.
const initialItems = [0, 1, 2, 3, 4]
const height = 70
const padding = 10
const size = 150

function Item({ total, index, onDelete }) {
    const controls = useAnimation()

    async function handleDragEnd(event, info) {
        const offset = info.offset.x
        const velocity = info.velocity.x

        if (offset < -100 || velocity < -500) {
            await controls.start({
                x: "-100%",
                transition: { duration: 0.2 },
            })

            onDelete(index)
        } else {
            controls.start({ x: 0, opacity: 1, transition: { duration: 0.5 } })
        }
    }

    return (
        <motion.div
            style={{
                width: 150,
                height: height,
                borderRadius: 20,
                overflow: "hidden",
                marginBottom: total - 1 === index ? 0 : 10,
                willChange: "transform",
                cursor: "grab",
            }}
            whileTap={{ cursor: "grabbing" }}
            positionTransition={{
                type: "spring",
                stiffness: 600,
                damping: 30,
            }}
        >
            <motion.div
                style={{
                    width: size,
                    height: height,
                    borderRadius: 20,
                    backgroundColor: "#fff",
                }}
                drag="x"
                dragDirectionLock
                onDragEnd={handleDragEnd}
                animate={controls}
            />
        </motion.div>
    )
}

export function FM35SwipeToDelete() {
    const y = useMotionValue(0)

    const [items, setItems] = React.useState(initialItems)
    const { top, bottom } = useConstraints(items)
    const controls = useAnimation()
    const totalScroll = getHeight(items)
    const scrollContainer = 150

    function onDelete(index) {
        const newItems = [...items]
        newItems.splice(index, 1)

        const newScrollHeight = getHeight(newItems)
        const bottomOffset = -y.get() + scrollContainer
        const bottomWillBeVisible = newScrollHeight < bottomOffset
        const isScrollHeightLarger = newScrollHeight >= scrollContainer

        if (bottomWillBeVisible && isScrollHeightLarger) {
            controls.start({
                y: -newScrollHeight + scrollContainer,
            })
        }

        setItems(newItems)
    }

    return (
        <Center>
            <div
                style={{
                    width: size,
                    height: size,
                    borderRadius: 30,
                    background: "transparent",
                    overflow: "hidden",
                    position: "relative",
                    transform: "translateZ(0)",
                }}
            >
                <motion.div
                    style={{ y: y, height: totalScroll }}
                    drag="y"
                    dragDirectionLock
                    dragConstraints={{ top, bottom }}
                    animate={controls}
                >
                    {items.map((value, index) => {
                        return (
                            <Item
                                total={items.length}
                                index={index}
                                onDelete={onDelete}
                                key={value}
                            />
                        )
                    })}
                </motion.div>
            </div>
        </Center>
    )
}

function getHeight(items) {
    const totalHeight = items.length * height
    const totalPadding = (items.length - 1) * padding
    const totalScroll = totalHeight + totalPadding
    return totalScroll
}

function useConstraints(items) {
    const [constraints, setConstraints] = React.useState({ top: 0, bottom: 0 })

    React.useEffect(() => {
        setConstraints({ top: size - getHeight(items), bottom: 0 })
    }, [items])

    return constraints
}

Some more details in the FM35SwipeToDelete() component:

  • The onDelete() function also checks for empty space at the bottom (which will occur when you delete the bottommost item) and will move the items down when needed (with a useAnimation()).
  • A reference to this onDelete() function is passed down to every <Item>.

Code component

This one is a bit simpler because the Framer library contains such handy components as scroll and stack.

const initialItems = [0, 1, 2, 3, 4]
const height = 70
const padding = 10
const size = 150

function Item({ total, index, onDelete }) {
    const controls = useAnimation()

    async function handleDragEnd(event, info) {
        const offset = info.offset.x
        const velocity = info.velocity.x

        if (offset < -100 || velocity < -500) {
            await controls.start({
                x: "-100%",
                transition: { duration: 0.2 },
            })

            onDelete(index)
        } else {
            controls.start({ x: 0, opacity: 1, transition: { duration: 0.5 } })
        }
    }

    return (
        <Frame
            // Visual & layout
            width={150}
            height={height}
            radius={20}
            backgroundColor="transparent"
            overflow="hidden"
            // Position transition
            positionTransition={{
                type: "spring",
                stiffness: 600,
                damping: 30,
            }}
        >
            <Frame
                // Visual & layout
                width={size}
                height={height}
                radius={20}
                backgroundColor="#fff"
                // Dragging
                drag="x"
                dragDirectionLock
                onDragEnd={handleDragEnd}
                // Animation
                animate={controls}
            />
        </Frame>
    )
}

export function CC35SwipeToDelete() {
    const contentOffsetY = useMotionValue(0)
    const contentHeight = useMotionValue(getHeight(initialItems))

    const [items, setItems] = React.useState(initialItems)

    const controls = useAnimation()

    function onDelete(index) {
        const newItems = [...items]
        newItems.splice(index, 1)

        contentHeight.set(getHeight(newItems))

        const bottomOffset = -contentOffsetY.get() + size
        const bottomWillBeVisible = contentHeight.get() < bottomOffset
        const isScrollHeightLarger = contentHeight.get() >= size

        if (bottomWillBeVisible && isScrollHeightLarger) {
            controls.start({
                y: -contentHeight.get() + size,
            })
        }

        setItems(newItems)
    }

    return (
        <Scroll
            // Visual & layout
            width={size}
            height={size}
            radius={30}
            center
            // Scrolling
            contentOffsetY={contentOffsetY}
            contentHeight={contentHeight.get()}
            // Scroll animation
            scrollAnimate={controls}
        >
            <Stack width={150} padding={0} gap={padding}>
                {items.map((value, index) => {
                    return (
                        <Item
                            total={items.length}
                            index={index}
                            onDelete={onDelete}
                            key={value}
                        />
                    )
                })}
            </Stack>
        </Scroll>
    )
}

function getHeight(items) {
    const totalHeight = items.length * height
    const totalPadding = (items.length - 1) * padding
    const totalScroll = totalHeight + totalPadding
    return totalScroll
}

Override

While it might be possible to create this interaction with overrides, it would be way more complicated. Not really worth the effort.


Leave a Reply