Menu

Swipeable Action List

A list where items can be swiped left or right to trigger actions.

Preview

Live interactive preview.

Installation

Add this component to your project using the CLI:

terminal
npx -y vui-registry-cli-v1@latest add swipeable-action-list

Source Code

swipeable-action-list.tsx
'use client'

import React, { useState } from 'react'
import { motion, AnimatePresence, PanInfo, useMotionValue, useTransform } from 'framer-motion'
import { Trash2, Archive, Check, Bell, MessageCircle, Mail, Calendar, Clock } from 'lucide-react'

// Custom Icons
const InstagramIcon = () => (
    <svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" viewBox="0 0 132.004 132" className="w-full h-full">
        <defs>
            <linearGradient id="b"><stop offset="0" stopColor="#3771c8"/><stop stopColor="#3771c8" offset=".128"/><stop offset="1" stopColor="#60f" stopOpacity="0"/></linearGradient>
            <linearGradient id="a"><stop offset="0" stopColor="#fd5"/><stop offset=".1" stopColor="#fd5"/><stop offset=".5" stopColor="#ff543e"/><stop offset="1" stopColor="#c837ab"/></linearGradient>
            <radialGradient id="c" cx="158.429" cy="578.088" r="65" xlinkHref="#a" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 -1.98198 1.8439 0 -1031.402 454.004)" fx="158.429" fy="578.088"/>
            <radialGradient id="d" cx="147.694" cy="473.455" r="65" xlinkHref="#b" gradientUnits="userSpaceOnUse" gradientTransform="matrix(.17394 .86872 -3.5818 .71718 1648.348 -458.493)" fx="147.694" fy="473.455"/>
        </defs>
        <path fill="url(#c)" d="M65.03 0C37.888 0 29.95.028 28.407.156c-5.57.463-9.036 1.34-12.812 3.22-2.91 1.445-5.205 3.12-7.47 5.468C4 13.126 1.5 18.394.595 24.656c-.44 3.04-.568 3.66-.594 19.188-.01 5.176 0 11.988 0 21.125 0 27.12.03 35.05.16 36.59.45 5.42 1.3 8.83 3.1 12.56 3.44 7.14 10.01 12.5 17.75 14.5 2.68.69 5.64 1.07 9.44 1.25 1.61.07 18.02.12 34.44.12 16.42 0 32.84-.02 34.41-.1 4.4-.207 6.955-.55 9.78-1.28 7.79-2.01 14.24-7.29 17.75-14.53 1.765-3.64 2.66-7.18 3.065-12.317.088-1.12.125-18.977.125-36.81 0-17.836-.04-35.66-.128-36.78-.41-5.22-1.305-8.73-3.127-12.44-1.495-3.037-3.155-5.305-5.565-7.624C116.9 4 111.64 1.5 105.372.596 102.335.157 101.73.027 86.19 0H65.03z" transform="translate(1.004 1)"/>
        <path fill="url(#d)" d="M65.03 0C37.888 0 29.95.028 28.407.156c-5.57.463-9.036 1.34-12.812 3.22-2.91 1.445-5.205 3.12-7.47 5.468C4 13.126 1.5 18.394.595 24.656c-.44 3.04-.568 3.66-.594 19.188-.01 5.176 0 11.988 0 21.125 0 27.12.03 35.05.16 36.59.45 5.42 1.3 8.83 3.1 12.56 3.44 7.14 10.01 12.5 17.75 14.5 2.68.69 5.64 1.07 9.44 1.25 1.61.07 18.02.12 34.44.12 16.42 0 32.84-.02 34.41-.1 4.4-.207 6.955-.55 9.78-1.28 7.79-2.01 14.24-7.29 17.75-14.53 1.765-3.64 2.66-7.18 3.065-12.317.088-1.12.125-18.977.125-36.81 0-17.836-.04-35.66-.128-36.78-.41-5.22-1.305-8.73-3.127-12.44-1.495-3.037-3.155-5.305-5.565-7.624C116.9 4 111.64 1.5 105.372.596 102.335.157 101.73.027 86.19 0H65.03z" transform="translate(1.004 1)"/>
        <path fill="#fff" d="M66.004 18c-13.036 0-14.672.057-19.792.29-5.11.234-8.598 1.043-11.65 2.23-3.157 1.226-5.835 2.866-8.503 5.535-2.67 2.668-4.31 5.346-5.54 8.502-1.19 3.053-2 6.542-2.23 11.65C18.06 51.327 18 52.964 18 66s.058 14.667.29 19.787c.235 5.11 1.044 8.598 2.23 11.65 1.227 3.157 2.867 5.835 5.536 8.503 2.667 2.67 5.345 4.314 8.5 5.54 3.054 1.187 6.543 1.996 11.652 2.23 5.12.233 6.755.29 19.79.29 13.037 0 14.668-.057 19.788-.29 5.11-.234 8.602-1.043 11.656-2.23 3.156-1.226 5.83-2.87 8.497-5.54 2.67-2.668 4.31-5.346 5.54-8.502 1.18-3.053 1.99-6.542 2.23-11.65.23-5.12.29-6.752.29-19.788 0-13.036-.06-14.672-.29-19.792-.24-5.11-1.05-8.598-2.23-11.65-1.23-3.157-2.87-5.835-5.54-8.503-2.67-2.67-5.34-4.31-8.5-5.535-3.06-1.187-6.55-1.996-11.66-2.23-5.12-.233-6.75-.29-19.79-.29zm-4.306 8.65c1.278-.002 2.704 0 4.306 0 12.816 0 14.335.046 19.396.276 4.68.214 7.22.996 8.912 1.653 2.24.87 3.837 1.91 5.516 3.59 1.68 1.68 2.72 3.28 3.592 5.52.657 1.69 1.44 4.23 1.653 8.91.23 5.06.28 6.58.28 19.39s-.05 14.33-.28 19.39c-.214 4.68-.996 7.22-1.653 8.91-.87 2.24-1.912 3.835-3.592 5.514-1.68 1.68-3.275 2.72-5.516 3.59-1.69.66-4.232 1.44-8.912 1.654-5.06.23-6.58.28-19.396.28-12.817 0-14.336-.05-19.396-.28-4.68-.216-7.22-.998-8.913-1.655-2.24-.87-3.84-1.91-5.52-3.59-1.68-1.68-2.72-3.276-3.592-5.517-.657-1.69-1.44-4.23-1.653-8.91-.23-5.06-.276-6.58-.276-19.398s.046-14.33.276-19.39c.214-4.68.996-7.22 1.653-8.912.87-2.24 1.912-3.84 3.592-5.52 1.68-1.68 3.28-2.72 5.52-3.592 1.692-.66 4.233-1.44 8.913-1.655 4.428-.2 6.144-.26 15.09-.27zm29.928 7.97c-3.18 0-5.76 2.577-5.76 5.758 0 3.18 2.58 5.76 5.76 5.76 3.18 0 5.76-2.58 5.76-5.76 0-3.18-2.58-5.76-5.76-5.76zm-25.622 6.73c-13.613 0-24.65 11.037-24.65 24.65 0 13.613 11.037 24.645 24.65 24.645C79.617 90.645 90.65 79.613 90.65 66S79.616 41.35 66.003 41.35zm0 8.65c8.836 0 16 7.163 16 16 0 8.836-7.164 16-16 16-8.837 0-16-7.164-16-16 0-8.837 7.163-16 16-16z"/>
    </svg>
)

const WhatsAppIcon = () => (
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" className="w-full h-full">
    <defs>
      <linearGradient id="wa-gradient" x1="1337.28" y1="518.24" x2="1337.28" y2="-2164.82" gradientTransform="matrix(0.19, 0, 0, -0.19, 0.81, 98.89)" gradientUnits="userSpaceOnUse">
        <stop offset="0" stopColor="#61fd7d"/>
        <stop offset="1" stopColor="#2bb826"/>
      </linearGradient>
    </defs>
    <path fill="url(#wa-gradient)" d="M512 382.07c0 2.8-.09 8.88-.26 13.58-.41 11.49-1.32 26.32-2.7 33.07a109.76 109.76 0 0 1-9.27 27.71 98.45 98.45 0 0 1-43.43 43.39 110.21 110.21 0 0 1-27.87 9.28c-6.69 1.35-21.41 2.24-32.82 2.65-4.71.17-10.79.25-13.58.25l-252.1 0c-2.8 0-8.88-.09-13.58-.26-11.49-.41-26.32-1.32-33.07-2.69a110.37 110.37 0 0 1-27.72-9.28A98.5 98.5 0 0 1 12.18 456.3 110.21 110.21 0 0 1 2.9 428.43C1.55 421.74.66 407 .25 395.61.08 390.91 0 384.82 0 382l0-252.1c0-2.8.09-8.88.25-13.58C.71 104.86 1.62 90 3 83.28a110.37 110.37 0 0 1 9.27-27.72A98.59 98.59 0 0 1 55.7 12.18 110.21 110.21 0 0 1 83.57 2.9C90.26 1.55 105 .66 116.39.25 121.09.08 127.18 0 130 0l252.1 0c2.8 0 8.88.09 13.58.25C407.14.71 422 1.62 428.72 3a110.37 110.37 0 0 1 27.72 9.27A98.59 98.59 0 0 1 499.82 55.7a110.21 110.21 0 0 1 9.28 27.87c1.35 6.69 2.24 21.41 2.65 32.82.17 4.7.25 10.79.25 13.58Z"/>
    <path fill="#fff" fillRule="evenodd" d="M379.56 131.67A172.4 172.4 0 0 0 256.67 80.73C161 80.73 83.05 158.64 83.05 254.42a173.47 173.47 0 0 0 23.05 86.66l-24.52 89.53 91.56-24a173.23 173.23 0 0 0 83.53 21.5h.07c95.63 0 173.6-77.93 173.64-173.71a172.6 172.6 0 0 0-50.78-122.73ZM256.74 400.1a144.15 144.15 0 0 1-73.45-20.09l-5.26-3.13-54.58 14.31 14.56-53.19-3.42-5.45a144.37 144.37 0 0 1-22.13-77.12c0-79.67 64.84-144.5 144.55-144.5s144.5 64.84 144.54 144.52-64.82 144.53-144.81 144.65Zm79.31-108.31c-4.34-2.17-25.69-12.67-29.68-14.12s-6.87-2.17-9.76 2.17c-2.9 4.34-11.22 14.12-13.75 17s-5.06 3.25-9.4 1.09-18.35-6.77-34.95-21.57c-13-11.6-21.77-25.92-24.31-30.26s-.27-6.69 1.9-8.85c2-1.94 4.34-5.06 6.51-7.59s3.08-4.16 4.52-7.05.72-5.42-.36-7.59-9.76-23.51-13.38-32.18c-3.52-8.45-7.1-7.31-9.76-7.44s-5.43-.13-8.32-.13-7.6 1.09-11.57 5.42-15.19 14.83-15.19 36.16 15.55 42 17.72 44.85 30.6 46.73 74.15 65.52c30.29 13.07 36.46 10.45 43.14 9.81s21.71-8.85 24.79-17.39 3.08-15.91 2.17-17.39c-.91-1.48-3.26-2.31-7.6-4.48Z"/>
  </svg>
)

const GmailIcon = () => (
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122.88 92.12" className="w-full h-full">
    <g>
      <path fill="#4285F4" d="M8.36 92.12h19.54V44.67L0 23.76v60.08C0 88.42 3.78 92.12 8.36 92.12L8.36 92.12z"/>
      <path fill="#34A853" d="M94.97 92.12h19.54c4.67 0 8.36-3.78 8.36-8.36v-60L94.97 44.67V92.12L94.97 92.12z"/>
      <path fill="#FBBC04" d="M94.97 8.4v36.27l27.91-20.91V12.58c0-10.37-11.82-16.25-20.11-10.05L94.97 8.4L94.97 8.4z"/>
      <polygon fill="#EA4335" points="27.9 44.67 27.9 8.4 61.44 33.57 94.97 8.4 94.97 44.67 61.44 69.76 27.9 44.67"/>
      <path fill="#C5221F" d="M0 12.58v11.18l27.91 20.91V8.4l-7.8-5.87C11.82-3.66 0 2.29 0 12.58L0 12.58z"/>
    </g>
  </svg>
)

const MusicIcon = () => (
    <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.88 122.88" xmlSpace="preserve" className="w-full h-full">
        <g>
            <g>
                <defs>
                    <path id="SVGID_1_" d="M18.43,0h86.02c10.18,0,18.43,8.25,18.43,18.43v86.02c0,10.18-8.25,18.43-18.43,18.43H18.43 C8.25,122.88,0,114.63,0,104.45l0-86.02C0,8.25,8.25,0,18.43,0L18.43,0z"/>
                </defs>
                <clipPath id="SVGID_2_">
                    <use xlinkHref="#SVGID_1_" style={{overflow: "visible"}}/>
                </clipPath>
                <g style={{clipPath: "url(#SVGID_2_)"}}>
                    <defs>
                        <rect id="SVGID_3_" width="122.88" height="122.88"/>
                    </defs>
                    <clipPath id="SVGID_4_">
                        <use xlinkHref="#SVGID_3_" style={{overflow: "visible"}}/>
                    </clipPath>
                    <g transform="matrix(1 0 0 1 0 -7.629395e-006)" style={{clipPath: "url(#SVGID_4_)"}}>
                        <image style={{overflow: "visible"}} width="260" height="259" xlinkHref="data:image/jpeg;base64,/9j/4AAQSkZJRgABAgEAlgCWAAD/7AARRHVja3kAAQAEAAAAHgAA/+4AIUFkb2JlAGTAAAAAAQMA EAMCAwYAAARVAAAFjAAACMr/2wCEABALCwsMCxAMDBAXDw0PFxsUEBAUGx8XFxcXFx8eFxoaGhoX Hh4jJSclIx4vLzMzLy9AQEBAQEBAQEBAQEBAQEABEQ8PERMRFRISFRQRFBEUGhQWFhQaJhoaHBoa JjAjHh4eHiMwKy4nJycuKzU1MDA1NUBAP0BAQEBAQEBAQEBAQP/CABEIAQQBBwMBIgACEQEDEQH/ xACrAAEBAQEBAQAAAAAAAAAAAAAAAQUEBgMBAQEBAQEBAQAAAAAAAAAAAAADBQEGBAIQAAEBBQYG AgMAAAAAAAAAAAUAMEAlNRYCEjMEFDYBMUIDQ0UyRBETFREAAAMHBAIDAQEBAAAAAAAAAAECUHHR krIDBHKiM0MSsxEhEzFBMhIAAQEFCAIBBAMAAAAAAAAAAAFAMXECA0GBsZIzQ3ODERIhUWEygkJy E//aAAwDAQACEQMRAAAA1eP5Z/zem1WU5XVZQ1WUNVlDVZQ1WUNVlDVZQ1WUNVlDVZQ1WUNVlDVZ Q1WUPZOVfDyM7Rz4bsVy0URRFEURRFEURRFEURRFEURR6cX89kZ+jnx24rlYoiiKIoiiKIoiiKIo iiKIoij04v5/Iz9HPjtRXKxRFEURRFEURRFEURRFEURRFHphfAyeDQz47IcqAAAAAAAAAAAAAB6Y XwMrP0OCWzFctFEURRFEURRFEURRFEURRFEUelFsDK4O/glshygAAAAAAAAAAAAAHpFXwsrg7+GO xFcpFEURRFEURRFEURRFEURRFEUejF8LL4e/hlsRXKRRFEURRFEURRFEURRFEURRFHohbDy+Hv4Z a8VykURRFEURRFEURRFEURRFEURR6FVsTM4e7ilrRXKRRFEURRFEURRFEURRFEURRFHoFWxMzh7u KetFcpFEURRFEURRFEURRFEURRFEUegFcXN4u3inqhygAAAAAAAAAAAAAG+quNm8XbxT1A5QAAAA AAAAAAAAADfFcbO4u3inqByoAAAAAAAAAAAAAG+K42bx9vFPUBQAAAAAAAAAAAAADeFMfm5T8/aH KAAAAAAAAAAAAAAbApl//9oACAECAAEFALVq1+b1pXrSvWletK9aV60r1pXrSvWletK9aV60r1pX rSvWl+eP67Xyb+O18m/jtfJv47Xyb+O1zb+O1zb+O1zb+O1zb+O1zb9HHm36OPNv0cebfo482/Rx 5t+jjzb9HHm36f/aAAgBAwABBQDsZXL8ezpMstJllpMstJllpMstJllpMstJllpMstJllpMstJll pMstJllpMsv12P6GWwG/s8vgN/ZZfAb+yy+A39ll8Bv7LL4Df2OXwW/sexgt/YdjBb+w7GC39h2M Fv8Af7GC3+/2MFv9/s4Tf73Zwm/3u1ht/t//2gAIAQEAAQUALHiWVI1MYVTGFUxhVMYVTGFUxhVM YVTGFUxhVMYVTGFUxhVMYVTGFUxhVMYVTGFUxhVMYVTGFUxhVMYVTGFUxhVMYVTGFUxhVMYVTGFU xhVMYQ4lm8wEPzh/D7ZPzd/D7ZPzd/D7aPzd/D7aPTd/D7aPTd/EbaPTd/EbbPTZ/EbbOzZ/EbbO zZ/EbcOzZ/E7cOTV/E7dOTV/E7dOTV/E7dNzV/FbeNzR/FbeNzR/FbfNTR/FbfNTR/F7fNTN/FyA zM38XIDMzfxcgMzN/GSEzM38ZITEyfxkiMTJ/GSIxMn8ZIjEyfxsiMTJ/GyIvMn8bIy8yfxsjJ/z 9dC1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1kNN/I//aAAgB AgIGPwBflXj1HqPUeo9R6j1HqPUeo9R6j1HqPU8/YWLB+osWC4WLBcLFguFiwXCxYLhYsFwsWC4W LBcLFguFiwXCxYLhYsFwsWC4WLBcK4sLCwsLCwsLCwsLCwsLCwuP/9oACAEDAgY/AKarSpqq05VV VlT6GjSyoaNLKho0sqGjSyoaNLKho0sqGjSyoaNLKho0sqGjSyoaNLKho0sqGjSyoaNLKho0sqHp 6y+n+3j18fHiBS4pMGDvKXFJgwd5S45MGDuKXHJgwdxS45MGDuKXHJgwdxS45cGDuKXHLgwdxT45 cGDuKfHLgwdpT45cGDtKfHLgwdpT45cGDtKfHLgwdpT/AKS4MHaSav4SugbxvG8bxvG8bxvG8bxv G8bxvG8fy1P2P//aAAgBAQEGPwC/Ys3CTbQZEkjSk/6kj/0hypkTAcqZEwHKmRMBypkTAcqZEwHK mRMBypkTAcqZEwHKmRMBypkTAcqZEwHKmRMBypkTAcqZEwHKmRMBypkTAcqZEwHKmRMBypkTAcqZ EwHKmRMBypkTAcqZEwHKmRMBypkTAcqZEwHKmRMBypkTAZOZdUR37RXTQr4I iLwR5F9PGTqKlLAzNN/1jJ1FSlgZmm/6xk6ipSwMzTf9YydRUpYGZpv+sZOoqUsDM03vWMnUVKWB mab3rGTqKlLAzNN71jJ1FSlgZmm96xk6ipJgZmm9QMnUVJMDM03qBkaipJgZem9QMjUVJMDLdeoG Q8qSYGW69QMh5UkwMt16gZDypJgZbr1AyHlSTAy3XqBkPKkmBluvUDIeVJMDLdeoGQ8qSYGU69QM h5UkwMp12gX3lSTAynXaBfeVJMDKddoF95UkwMp12gX3lSTAynXaBfeVJMDKddoF95UkwMp12gX3 lSTAynXaBfeVJMDJddoF95UkwMl12gX3lSTAyXXaBfeVJMDJddoF79f2/T5Ly8fHx/hfz5HfsHfs HfsHfsHfsHfsHfsHfsHfsHfsHfsHfsHfsHfsHfsHfsHfsHfsHfsHfsHfsHfsHfsHfsHfsHfsHfsH fsHfsHfsHfsF/wDPz/H4ueXl8ef/AD9/Hx9D/9k=" transform="matrix(0.48 0 0 -0.48 -0.6914 123.5713)"/></g></g></g><path fill="#FFFFFF" d="M47.76,86.16v-38.4c0-1.44,0.8-2.32,2.4-2.64l33.12-6.72c1.76-0.32,2.72,0.48,2.88,2.4v29.28 c0,2.4-3.6,4-10.8,4.8c-13.68,2.16-11.52,25.2,7.2,18.96c7.2-2.64,8.4-9.6,8.4-16.56V21.12c0,0,0-4.8-4.08-3.6l-40.8,8.4 c0,0-3.12,0.48-3.12,4.32v48.72c0,2.4-3.6,4-10.8,4.8c-13.68,2.16-11.52,25.2,7.2,18.96C46.56,100.08,47.76,93.12,47.76,86.16 L47.76,86.16z"/></g></svg>
)

interface Item {
  id: number
  app: string
  title: string
  subtitle: string
  time: string
  icon: React.ReactNode
  appColor: string
  unread?: boolean
  stackCount?: number
  isStackItem?: boolean
}

const initialItems: Item[] = [
  { 
    id: 1, 
    app: 'Instagram',
    title: 'Instagram', 
    subtitle: 'Sarah, Mike and 3 others liked your story', 
    time: '2m ago',
    icon: <InstagramIcon />,
    appColor: 'bg-white',
    unread: true,
    stackCount: 4
  },
  { 
    id: 2, 
    app: 'WhatsApp',
    title: 'WhatsApp', 
    subtitle: 'Mom: Are you coming for dinner tonight?', 
    time: '15m ago',
    icon: <WhatsAppIcon />,
    appColor: 'bg-white',
    unread: true
  },
  { 
    id: 3, 
    app: 'Mail',
    title: 'Mail', 
    subtitle: 'Linear: Payment invoice #1024 processed', 
    time: '1h ago',
    icon: <GmailIcon />,
    appColor: 'bg-white p-2',
  },
  { 
    id: 4, 
    app: 'Calendar',
    title: 'Calendar', 
    subtitle: 'Design Review in 30 minutes', 
    time: '11:30 AM',
    icon: <Calendar size={24} className="text-neutral-900" />,
    appColor: 'bg-white border border-neutral-200',
  },
]

function SwipeableItem({ item, onAction, index, onToggleStack, isExpanded }: { 
    item: Item; 
    onAction: (id: number, action: 'delete' | 'archive') => void; 
    index: number;
    onToggleStack?: (id: number) => void;
    isExpanded?: boolean;
}) {
  const x = useMotionValue(0)
  const background = useTransform(x, [-100, 0, 100], ['rgba(239, 68, 68, 0.2)', 'transparent', 'rgba(59, 130, 246, 0.2)'])
  const opacityRight = useTransform(x, [-50, -100], [0, 1])
  const opacityLeft = useTransform(x, [50, 100], [0, 1])
  
  const handleDragEnd = (_: any, info: PanInfo) => {
    if (info.offset.x < -100) {
      onAction(item.id, 'delete')
    } else if (info.offset.x > 100) {
      onAction(item.id, 'archive')
    }
  }

  const handleClick = () => {
    if (item.stackCount && onToggleStack) {
        onToggleStack(item.id)
    }
  }

  return (
    <motion.div 
        className="relative w-full mb-3"
        style={{ 
            zIndex: 100 - index,
            marginLeft: item.isStackItem ? '0' : '0',
            width: '100%'
        }}
        layout
    >
      {/* Background Actions Layer */}
      <motion.div
        className="absolute inset-0 flex items-center justify-between px-6 rounded-3xl overflow-hidden backdrop-blur-3xl"
        style={{ background }}
      >
        <motion.div style={{ opacity: opacityLeft }} className="flex items-center gap-2 text-blue-500 font-semibold pl-2">
          <Archive size={20} />
          <span>Archive</span>
        </motion.div>
        <motion.div style={{ opacity: opacityRight }} className="flex items-center gap-2 text-red-500 font-semibold pr-2">
          <span>Delete</span>
          <Trash2 size={20} />
        </motion.div>
      </motion.div>

      {/* Stack Effect Layers (only visible if not expanded) */}
      {item.stackCount && item.stackCount > 0 && !isExpanded && (
        <>
            <div className="absolute inset-x-4 bottom-[-8px] h-full bg-white/40 dark:bg-neutral-800/40 rounded-3xl transform scale-[0.96] z-[-1] shadow-sm transition-all duration-300" />
            <div className="absolute inset-x-8 bottom-[-16px] h-full bg-white/20 dark:bg-neutral-800/20 rounded-3xl transform scale-[0.92] z-[-2] shadow-sm transition-all duration-300" />
        </>
      )}

      {/* Foreground Card Layer */}
      <motion.div
        className={`relative bg-white/70 dark:bg-[#1A1A1A] backdrop-blur-xl border border-white/40 dark:border-white/5 flex items-center p-4 gap-4 rounded-3xl shadow-[0_8px_32px_-6px_rgba(0,0,0,0.1)] hover:shadow-[0_8px_40px_-4px_rgba(0,0,0,0.15)] transition-all group ${item.stackCount ? 'cursor-pointer active:scale-[0.98]' : 'cursor-grab active:cursor-grabbing'}`}
        drag={item.stackCount ? false : "x"}
        dragConstraints={{ left: 0, right: 0 }}
        dragElastic={0.1}
        onDragEnd={handleDragEnd}
        onClick={handleClick}
        style={{ x }}
        whileTap={item.stackCount ? undefined : { scale: 0.98 }}
      >
        <div className="relative shrink-0">
            <div className={`w-12 h-12 rounded-2xl flex items-center justify-center shadow-lg ${item.appColor} overflow-hidden`}>
                {item.icon}
            </div>
            {item.unread && (
                <div className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full border-2 border-white dark:border-[#1A1A1A] flex items-center justify-center shadow-sm z-10">
                    {item.stackCount ? (
                        <span className="text-[10px] font-semibold text-white">{item.stackCount}</span>
                    ) : (
                        <span className="text-[10px] font-semibold text-white">1</span>
                    )}
                </div>
            )}
        </div>
        
        <div className="flex-1 min-w-0 py-1">
          <div className="flex justify-between items-baseline mb-1">
            <h3 className="text-[15px] font-semibold text-neutral-900 dark:text-white truncate flex items-center gap-2">
                {item.title}
                {item.stackCount && (
                    <span className="text-[10px] px-1.5 py-0.5 bg-neutral-100 dark:bg-neutral-800 text-neutral-500 rounded-full font-medium">
                        +{item.stackCount}
                    </span>
                )}
            </h3>
            <span className="text-xs text-neutral-400 dark:text-neutral-500 font-medium">{item.time}</span>
          </div>
          <p className={`text-[13px] truncate leading-snug ${item.unread ? 'text-neutral-900 dark:text-neutral-200 font-medium' : 'text-neutral-500 dark:text-neutral-400'}`}>
            {item.subtitle}
          </p>
        </div>
      </motion.div>
    </motion.div>
  )
}

export default function SwipeableActionList() {
  const [items, setItems] = useState(initialItems)
  const [expandedIds, setExpandedIds] = useState<number[]>([])

  const handleAction = (id: number, action: 'delete' | 'archive') => {
    setItems((prev) => prev.filter((item) => item.id !== id))
  }

  const handleToggleStack = (id: number) => {
    if (expandedIds.includes(id)) {
        setExpandedIds(prev => prev.filter(i => i !== id))
        // Remove dummy items
        setItems(prev => prev.filter(item => !item.isStackItem))
    } else {
        setExpandedIds(prev => [...prev, id])
        // Add dummy items for demo
        const parentItem = items.find(i => i.id === id)
        if (parentItem && parentItem.app === 'Instagram') {
            const newItems: Item[] = [
                {
                    id: Date.now() + 1,
                    app: 'Instagram',
                    title: 'karan',
                    subtitle: 'Liked your story',
                    time: '2m ago',
                    icon: <InstagramIcon />,
                    appColor: 'bg-white',
                    unread: true,
                    isStackItem: true
                },
                {
                    id: Date.now() + 2,
                    app: 'Instagram',
                    title: 'parivesh',
                    subtitle: 'Alex commented: "Great shot!"',
                    time: '5m ago',
                    icon: <InstagramIcon />,
                    appColor: 'bg-white',
                    unread: true,
                    isStackItem: true
                },
                {
                    id: Date.now() + 3,
                    app: 'Instagram',
                    title: 'ekansh',
                    subtitle: 'New follower: jessica_design',
                    time: '12m ago',
                    icon: <InstagramIcon />,
                    appColor: 'bg-white',
                    unread: true,
                    isStackItem: true
                },
                {
                    id: Date.now() + 4,
                    app: 'Instagram',
                    title: 'Instagram',
                    subtitle: 'Your reel reached 10k views',
                    time: '1h ago',
                    icon: <InstagramIcon />,
                    appColor: 'bg-white',
                    isStackItem: true
                }
            ]
            
            // Insert after parent
            const index = items.findIndex(i => i.id === id)
            const newItemsList = [...items]
            newItemsList.splice(index + 1, 0, ...newItems)
            setItems(newItemsList)
        }
    }
  }

  return (
    <div className="w-full min-h-[600px] flex items-center justify-center bg-[url('https://images.unsplash.com/photo-1697216172559-864070732851?q=80&w=2560&auto=format&fit=crop')] bg-cover bg-center p-8 transition-colors duration-500 relative overflow-hidden">
      {/* Overlay for readability */}
      <div className="absolute inset-0 bg-neutral-100/30 dark:bg-black/40 backdrop-blur-3xl" />

      <div className="w-full max-w-sm relative z-10">
        <div className="flex justify-between items-center mb-8 px-2">
            <div className="flex items-center gap-3">
                <div className="w-12 h-12 bg-white/20 dark:bg-black/20 backdrop-blur-md rounded-2xl flex items-center justify-center shadow-lg border border-white/20">
                    <Bell size={20} className="text-neutral-900 dark:text-white" />
                </div>
                <div>
                    <h2 className="text-2xl font-bold text-neutral-900 dark:text-white leading-none tracking-tight">Notifications</h2>
                    <p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1 font-medium">You have {items.length} unread</p>
                </div>
            </div>
            <button 
                onClick={() => setItems(initialItems)}
                className="w-10 h-10 flex items-center justify-center text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors bg-white/10 hover:bg-white/20 rounded-full backdrop-blur-sm"
            >
                <Clock size={20} />
            </button>
        </div>
        
        <div className="relative">
            <AnimatePresence mode="popLayout">
            {items.map((item, index) => (
                <motion.div
                key={item.id}
                layout
                initial={{ opacity: 0, scale: 0.9, y: 20, marginLeft: 0 }}
                animate={{ 
                    opacity: 1, 
                    scale: 1, 
                    y: 0,
                    zIndex: 100 - index,
                    marginLeft: 0
                }}
                exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.2 } }}
                transition={{ type: 'spring', stiffness: 500, damping: 40, mass: 1 }}
                >
                <SwipeableItem 
                    item={item} 
                    onAction={handleAction} 
                    index={index} 
                    onToggleStack={handleToggleStack}
                    isExpanded={expandedIds.includes(item.id)}
                />
                </motion.div>
            ))}
            </AnimatePresence>
            
            {items.length === 0 && (
                <motion.div 
                    initial={{ opacity: 0, scale: 0.9 }} 
                    animate={{ opacity: 1, scale: 1 }}
                    className="flex flex-col items-center justify-center py-20 text-neutral-500"
                >
                    <div className="w-24 h-24 bg-white/10 dark:bg-black/10 backdrop-blur-md rounded-full flex items-center justify-center mb-6 border border-white/20 shadow-xl">
                        <Check size={40} className="text-green-500" />
                    </div>
                    <h3 className="text-xl font-bold text-neutral-900 dark:text-white mb-2">All caught up!</h3>
                    <p className="text-neutral-600 dark:text-neutral-400">No new notifications at the moment.</p>
                </motion.div>
            )}
        </div>
      </div>
    </div>
  )
}

Dependencies

  • framer-motion: latest
  • lucide-react: latest

Props

Component property reference.

NameTypeDefaultDescription
itemsItem[]-Array of items to display with id, app, title, subtitle, time, icon, appColor, unread, and optional stackCount metadata.
Context Worth Keeping In Orbit

Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.

I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.

I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.