Skip to content

Swipe Actions ​

List rows that swipe to reveal actions, rubber-band, full-swipe to commit and collapse away.

Inbox

  • Lena Ortiz

    9:41

    Q3 roadmap review

    Moved the review to Thursday — can you bring the latency numbers?

  • Figma

    9:12

    Kai commented on “Checkout v4”

    “Should the promo field collapse by default on mobile?”

  • Marcus Lee

    Tue

    Lunch on Friday?

    The new ramen place opened two blocks from the office.

  • Billing

    Mon

    Your invoice is ready

    Invoice #4821 for October is available to download.

Swipe a row left or right. Swipe all the way to delete, or use the ⋯ menu.

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/swipe-actions.json
Manual installation

Install the dependencies:

bash
npm install motion-v

Then copy these files into components/ui/swipe-actions/ (plus lib/utils.ts if you don't have it):

vue
<script setup lang="ts" generic="T extends Record<string, any>">
import type { HTMLAttributes } from 'vue'
import type { SwipeAction } from './types'
import { AnimatePresence, motion } from 'motion-v'
import { ref } from 'vue'
import { cn } from '@/lib/utils'
import SwipeActionsRow from './SwipeActionsRow.vue'

const props = withDefaults(defineProps<{
  items: T[]
  /** Revealed by swiping right (or a function of the row). The first one is the primary, full-swipe action. */
  leading?: SwipeAction[] | ((item: T) => SwipeAction[])
  /** Revealed by swiping left (or a function of the row). The first one is the primary, full-swipe action. */
  trailing?: SwipeAction[] | ((item: T) => SwipeAction[])
  /** Property used as the row key. */
  itemKey?: keyof T & string
  /** Property used as the row's accessible name (for its actions menu). */
  itemLabel?: keyof T & string
  /** Release past `threshold` to trigger the primary action. */
  fullSwipe?: boolean
  /** Fraction of the row width that arms a full swipe. */
  threshold?: number
  /** Accessible name of the list. */
  label?: string
  class?: HTMLAttributes['class']
}>(), {
  leading: () => [],
  trailing: () => [],
  itemKey: 'id' as any,
  itemLabel: 'title' as any,
  fullSwipe: true,
  threshold: 0.55,
})

const emit = defineEmits<{
  /** Remove the item from `items` in your handler and its row collapses away. */
  action: [action: SwipeAction, item: T]
}>()

defineSlots<{
  default?: (props: { item: T, index: number }) => any
}>()

const menuFor = ref<unknown>(null)
// Only one row stays open at a time.
const openKey = ref<unknown>(null)
const resolve = (actions: SwipeAction[] | ((item: T) => SwipeAction[]), item: T) =>
  typeof actions === 'function' ? actions(item) : actions
const COLLAPSE = { type: 'spring', stiffness: 420, damping: 40 } as const
</script>

<template>
  <ul
    role="list"
    :aria-label="props.label"
    :class="cn('m-0 list-none divide-y p-0', props.class)"
  >
    <AnimatePresence :initial="false">
      <motion.li
        v-for="(item, index) in props.items"
        :key="item[props.itemKey]"
        layout="position"
        :class="cn('relative', menuFor === item[props.itemKey] ? 'z-10' : 'overflow-hidden')"
        :exit="{ height: 0, opacity: 0, transition: { height: COLLAPSE, opacity: { duration: 0.2 } } }"
      >
        <SwipeActionsRow
          :leading="resolve(props.leading, item)"
          :trailing="resolve(props.trailing, item)"
          :label="String(item[props.itemLabel] ?? `Item ${index + 1}`)"
          :full-swipe="props.fullSwipe"
          :threshold="props.threshold"
          :active="openKey === item[props.itemKey]"
          @open="openKey = $event ? item[props.itemKey] : (openKey === item[props.itemKey] ? null : openKey)"
          @action="emit('action', $event, item)"
          @menu="menuFor = $event ? item[props.itemKey] : (menuFor === item[props.itemKey] ? null : menuFor)"
        >
          <slot :item="item" :index="index" />
        </SwipeActionsRow>
      </motion.li>
    </AnimatePresence>
  </ul>
</template>
vue
<script setup lang="ts">
import type { SwipeAction, SwipeSide } from './types'
import { animate, motion, useDomRef, useMotionValue, useReducedMotion, useTransform } from 'motion-v'
import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
import { cn } from '@/lib/utils'

const props = defineProps<{
  leading: SwipeAction[]
  trailing: SwipeAction[]
  /** Accessible name of the row, used for the actions menu. */
  label: string
  fullSwipe: boolean
  /** Fraction of the row width past which a release triggers the primary action. */
  threshold: number
  /** False once another row opens, which closes this one. */
  active?: boolean
  disabled?: boolean
}>()

const emit = defineEmits<{
  action: [action: SwipeAction]
  menu: [open: boolean]
  open: [open: boolean]
}>()

const ACTION_WIDTH = 76
const SNAP = { type: 'spring', stiffness: 500, damping: 40 } as const
const FLING = { type: 'spring', stiffness: 260, damping: 32 } as const
const POP = { type: 'spring', stiffness: 600, damping: 18 } as const

const reduced = useReducedMotion()
const x = useMotionValue(0)
const clip = ref<HTMLElement>()
const armed = ref<SwipeSide | null>(null)

const leadWidth = computed(() => props.leading.length * ACTION_WIDTH)
const trailWidth = computed(() => props.trailing.length * ACTION_WIDTH)

// Tray widths and reveal progress follow the motion value — no re-render per frame.
const leadTray = useTransform(x, v => `${Math.max(0, v)}px`)
const trailTray = useTransform(x, v => `${Math.max(0, -v)}px`)
const leadProgress = useTransform(x, v => Math.min(1, Math.max(0, v / (leadWidth.value || 1))))
const trailProgress = useTransform(x, v => Math.min(1, Math.max(0, -v / (trailWidth.value || 1))))
const leadIconScale = useTransform(leadProgress, p => 0.5 + p * 0.5)
const trailIconScale = useTransform(trailProgress, p => 0.5 + p * 0.5)

// Trailing actions sit on the right; the primary (first) one is the outermost.
const trailingShown = computed(() => [...props.trailing].reverse())

let gesture: { x: number, y: number, origin: number, width: number, t: number, vx: number } | null = null
let dragging = false
let suppressClick = false
let gone = false
onBeforeUnmount(() => {
  gone = true
  closeMenu(false)
})

/** Soft limit: free travel up to `limit`, then resistance that never quite stops. */
function band(value: number, limit: number, width: number) {
  const sign = Math.sign(value)
  const abs = Math.abs(value)
  if (abs <= limit)
    return value
  const over = abs - limit
  // With full-swipe on, travel stays light so a long swipe can reach the threshold.
  const resisted = props.fullSwipe && limit > 0
    ? over * 0.85
    : (1 - 1 / ((over * 0.55) / width + 1)) * width * 0.35
  return sign * (limit + resisted)
}

function onPointerDown(e: PointerEvent) {
  if (props.disabled || e.button !== 0)
    return
  gesture = { x: e.clientX, y: e.clientY, origin: x.get(), width: clip.value?.offsetWidth ?? 320, t: e.timeStamp, vx: 0 }
  dragging = false
  emit('open', true)
}

function onPointerMove(e: PointerEvent) {
  if (!gesture)
    return
  const dx = e.clientX - gesture.x
  const dy = e.clientY - gesture.y
  if (!dragging) {
    if (Math.abs(dx) < 6 && Math.abs(dy) < 6)
      return
    // Mostly vertical: let the page scroll.
    if (Math.abs(dy) > Math.abs(dx)) {
      gesture = null
      return
    }
    dragging = true
    ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
  }
  const raw = gesture.origin + dx
  const limit = raw > 0 ? leadWidth.value : trailWidth.value
  const next = band(raw, limit, gesture.width)
  const dt = Math.max(1, e.timeStamp - gesture.t)
  gesture.vx = (next - x.get()) / dt
  gesture.t = e.timeStamp
  x.set(next)

  const reach = gesture.width * props.threshold
  const side: SwipeSide | null = !props.fullSwipe
    ? null
    : next > reach && props.leading.length
      ? 'leading'
      : next < -reach && props.trailing.length
        ? 'trailing'
        : null
  armed.value = side
}

function onPointerUp() {
  if (!gesture)
    return
  const { vx, width } = gesture
  gesture = null
  if (!dragging)
    return
  dragging = false
  suppressClick = true
  setTimeout(() => (suppressClick = false))

  if (armed.value) {
    const side = armed.value
    const action = side === 'leading' ? props.leading[0]! : props.trailing[0]!
    commit(action, side, width)
    return
  }
  const v = x.get()
  const flick = Math.abs(vx) > 0.5
  if (v < 0 && props.trailing.length && (v < -trailWidth.value / 2 || (flick && vx < 0)))
    snapTo(-trailWidth.value)
  else if (v > 0 && props.leading.length && (v > leadWidth.value / 2 || (flick && vx > 0)))
    snapTo(leadWidth.value)
  else
    snapTo(0)
}

function snapTo(target: number) {
  emit('open', target !== 0)
  animate(x, target, reduced.value ? { duration: 0 } : SNAP)
}

watch(() => props.active, (active) => {
  if (!active && x.get() !== 0 && !gesture)
    snapTo(0)
})

/** Slide the row all the way out, then report the action. */
async function commit(action: SwipeAction, side: SwipeSide, width: number) {
  const target = side === 'leading' ? width : -width
  if (!reduced.value)
    await animate(x, target, FLING)
  emit('action', action)
  await nextTick()
  // Still here? The parent kept the item (e.g. "pin"), so slide back.
  if (!gone)
    setTimeout(() => {
      armed.value = null
      if (!gone)
        snapTo(0)
    }, 120)
}

function run(action: SwipeAction) {
  emit('action', action)
  nextTick(() => {
    if (!gone)
      snapTo(0)
  })
}

function onContentClick(e: MouseEvent) {
  if (suppressClick) {
    e.stopPropagation()
    e.preventDefault()
    return
  }
  if (x.get() !== 0) {
    e.stopPropagation()
    e.preventDefault()
    snapTo(0)
  }
}

// ---------------------------------------------------------------------------
// Keyboard alternative: an actions menu
// ---------------------------------------------------------------------------
const menuId = `vx-swipe-menu-${useId()}`
const menuOpen = ref(false)
const menuUp = ref(false)
const trigger = ref<HTMLButtonElement>()
const menu = useDomRef()
const allActions = computed(() => [...props.leading, ...props.trailing])

function onDocPointer(e: PointerEvent) {
  const t = e.target as Node
  if (!(menu.value as HTMLElement | null)?.contains(t) && !trigger.value?.contains(t))
    closeMenu(false)
}

async function openMenu() {
  if (typeof window === 'undefined' || !trigger.value)
    return
  snapTo(0)
  const rect = trigger.value.getBoundingClientRect()
  menuUp.value = window.innerHeight - rect.bottom < allActions.value.length * 40 + 24
  menuOpen.value = true
  emit('menu', true)
  document.addEventListener('pointerdown', onDocPointer, true)
  await nextTick()
  focusItem(0)
}

function closeMenu(returnFocus = true) {
  if (!menuOpen.value)
    return
  menuOpen.value = false
  emit('menu', false)
  if (typeof document !== 'undefined')
    document.removeEventListener('pointerdown', onDocPointer, true)
  if (returnFocus)
    trigger.value?.focus()
}

function focusItem(i: number) {
  const items = (menu.value as HTMLElement | null)?.querySelectorAll<HTMLElement>('[role="menuitem"]')
  if (!items?.length)
    return
  items[(i + items.length) % items.length]!.focus()
}

function onMenuKeydown(e: KeyboardEvent) {
  const items = [...((menu.value as HTMLElement | null)?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])]
  const i = items.indexOf(document.activeElement as HTMLElement)
  const keys: Record<string, () => void> = {
    ArrowDown: () => focusItem(i + 1),
    ArrowUp: () => focusItem(i - 1),
    Home: () => focusItem(0),
    End: () => focusItem(items.length - 1),
    Escape: () => closeMenu(),
    Tab: () => closeMenu(false),
  }
  const fn = keys[e.key]
  if (!fn)
    return
  if (e.key !== 'Tab')
    e.preventDefault()
  fn()
}

function pick(action: SwipeAction) {
  closeMenu()
  emit('action', action)
}

function onRowKeydown(e: KeyboardEvent) {
  if (e.key === 'Escape' && x.get() !== 0)
    snapTo(0)
}

const tones = {
  default: 'bg-primary text-primary-foreground',
  brand: 'bg-brand text-background',
  muted: 'bg-muted-foreground text-background',
  destructive: 'bg-destructive text-white',
}
</script>

<template>
  <div class="relative" @keydown="onRowKeydown">
    <div ref="clip" class="relative overflow-hidden">
      <!-- Leading tray (revealed by swiping right) -->
      <motion.div
        v-if="props.leading.length"
        aria-hidden="true"
        class="absolute inset-y-0 left-0 flex overflow-hidden"
        :style="{ width: leadTray }"
      >
        <motion.button
          v-for="(action, i) in props.leading"
          :key="action.id"
          type="button"
          tabindex="-1"
          :class="cn('flex min-w-0 basis-0 flex-col items-center justify-center gap-1 overflow-hidden text-xs font-medium', tones[action.tone ?? 'default'], action.class)"
          :animate="{ flexGrow: armed === 'leading' ? (i === 0 ? 1 : 0.0001) : 1 }"
          :transition="SNAP"
          @click="run(action)"
        >
          <motion.span class="flex flex-col items-center gap-1" :style="{ scale: leadIconScale, opacity: leadProgress }">
            <motion.span
              class="flex"
              :animate="{ scale: armed === 'leading' && i === 0 ? 1.2 : 1 }"
              :transition="POP"
            >
              <component :is="action.icon" v-if="action.icon" class="size-5" />
            </motion.span>
            <span class="whitespace-nowrap">{{ action.label }}</span>
          </motion.span>
        </motion.button>
      </motion.div>

      <!-- Trailing tray (revealed by swiping left) -->
      <motion.div
        v-if="props.trailing.length"
        aria-hidden="true"
        class="absolute inset-y-0 right-0 flex overflow-hidden"
        :style="{ width: trailTray }"
      >
        <motion.button
          v-for="(action, i) in trailingShown"
          :key="action.id"
          type="button"
          tabindex="-1"
          :class="cn('flex min-w-0 basis-0 flex-col items-center justify-center gap-1 overflow-hidden text-xs font-medium', tones[action.tone ?? 'default'], action.class)"
          :animate="{ flexGrow: armed === 'trailing' ? (i === trailingShown.length - 1 ? 1 : 0.0001) : 1 }"
          :transition="SNAP"
          @click="run(action)"
        >
          <motion.span class="flex flex-col items-center gap-1" :style="{ scale: trailIconScale, opacity: trailProgress }">
            <motion.span
              class="flex"
              :animate="{ scale: armed === 'trailing' && i === trailingShown.length - 1 ? 1.2 : 1 }"
              :transition="POP"
            >
              <component :is="action.icon" v-if="action.icon" class="size-5" />
            </motion.span>
            <span class="whitespace-nowrap">{{ action.label }}</span>
          </motion.span>
        </motion.button>
      </motion.div>

      <!-- Foreground content -->
      <motion.div
        :class="cn('relative flex touch-pan-y items-center bg-card', !props.disabled && 'cursor-grab active:cursor-grabbing')"
        :style="{ x }"
        @pointerdown="onPointerDown"
        @pointermove="onPointerMove"
        @pointerup="onPointerUp"
        @pointercancel="onPointerUp"
        @click.capture="onContentClick"
      >
        <div class="min-w-0 flex-1 select-none">
          <slot />
        </div>
        <button
          v-if="allActions.length"
          ref="trigger"
          type="button"
          :aria-label="`Actions for ${props.label}`"
          aria-haspopup="menu"
          :aria-expanded="menuOpen"
          :aria-controls="menuOpen ? menuId : undefined"
          :disabled="props.disabled"
          class="mr-3 inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
          @pointerdown.stop
          @click="menuOpen ? closeMenu() : openMenu()"
        >
          <svg class="size-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.75" /><circle cx="12" cy="12" r="1.75" /><circle cx="19" cy="12" r="1.75" /></svg>
        </button>
      </motion.div>
    </div>

    <motion.div
      v-if="menuOpen"
      :id="menuId"
      ref="menu"
      role="menu"
      :aria-label="`Actions for ${props.label}`"
      :class="cn(
        'absolute right-12 z-20 min-w-40 rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg',
        menuUp ? 'bottom-2 origin-bottom-right' : 'top-2 origin-top-right',
      )"
      :initial="{ opacity: 0, scale: 0.92, y: menuUp ? 6 : -6 }"
      :animate="{ opacity: 1, scale: 1, y: 0 }"
      :transition="{ type: 'spring', stiffness: 500, damping: 32 }"
      @keydown="onMenuKeydown"
    >
      <button
        v-for="action in allActions"
        :key="action.id"
        type="button"
        role="menuitem"
        tabindex="-1"
        :class="cn(
          'flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent focus-visible:bg-accent',
          action.tone === 'destructive' && 'text-destructive',
        )"
        @click="pick(action)"
      >
        <component :is="action.icon" v-if="action.icon" class="size-4 opacity-80" />
        {{ action.label }}
      </button>
    </motion.div>
  </div>
</template>
ts
import type { Component } from 'vue'

export type SwipeActionTone = 'default' | 'brand' | 'muted' | 'destructive'

export interface SwipeAction {
  /** Emitted back to you with the `action` event. */
  id: string
  label: string
  /** Any component, e.g. a lucide icon. */
  icon?: Component
  tone?: SwipeActionTone
  /** Extra classes for the action surface (e.g. a custom background). */
  class?: string
}

export type SwipeSide = 'leading' | 'trailing'
ts
export { default as SwipeActions } from './SwipeActions.vue'
export type { SwipeAction, SwipeActionTone, SwipeSide } from './types'

Usage ​

ts
import { SwipeActions, type SwipeAction } from '@/components/ui/swipe-actions'
import { Archive, Pin, Trash2 } from 'lucide-vue-next'

const trailing: SwipeAction[] = [
  { id: 'delete', label: 'Delete', icon: Trash2, tone: 'destructive' }, // primary: full swipe
  { id: 'archive', label: 'Archive', icon: Archive },
]

function onAction(action: SwipeAction, mail: Mail) {
  // Removing the item collapses its row with a height animation.
  if (action.id === 'delete')
    mails.value = mails.value.filter(m => m.id !== mail.id)
}
vue
<SwipeActions
  :items="mails"
  :leading="[{ id: 'pin', label: 'Pin', icon: Pin, tone: 'brand' }]"
  :trailing="trailing"
  @action="onAction"
>
  <template #default="{ item }">
    <div class="px-4 py-3">{{ item.title }}</div>
  </template>
</SwipeActions>

Drag a row sideways to reveal its actions; release past half the tray to keep it open, or past threshold to slide the row out and fire the primary (first) action. Each row also has a ⋯ menu button listing every action, so keyboard and screen-reader users get the same options (arrow keys, Home/End, Escape).

API ​

PropTypeDefaultDescription
itemsT[]—Rows to render.
leadingSwipeAction[] | (item: T) => SwipeAction[][]Revealed by swiping right. The first is the primary action.
trailingSwipeAction[] | (item: T) => SwipeAction[][]Revealed by swiping left. The first is the primary action.
itemKeystring'id'Property used as the row key.
itemLabelstring'title'Property used to name each row's actions menu.
fullSwipebooleantrueRelease past threshold to trigger the primary action.
thresholdnumber0.55Fraction of the row width that arms a full swipe.
labelstring—Accessible name of the list.

SwipeAction: { id: string, label: string, icon?: Component, tone?: 'default' \| 'brand' \| 'muted' \| 'destructive', class?: string }.

EventPayloadDescription
action(action: SwipeAction, item: T)An action was tapped, picked from the menu or full-swiped.
SlotPropsDescription
default{ item, index }Row content.

Designed by Kuldeep Pisda · Released under the MIT License