Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Swipeout

Swipe a list row to reveal leading and trailing actions.

Swipe a list row horizontally to reveal action buttons behind it. Dragging the content translates it to expose the trailing (and optional leading) action panel, then snaps open or closed on release with a momentum-feel transition. Only one row stays open at a time, and an outside tap or a scroll closes it — coordinated through a shared registry, like the overlay stack.

Installation

npx touchcn add swipeout

Usage

<tcn-swipeout>
  <tcn-swipeout-actions side="trailing">
    <button tcnSwipeoutAction variant="destructive" (click)="remove()">Delete</button>
  </tcn-swipeout-actions>
  <div class="row">Meeting notes</div>
</tcn-swipeout>
import { TcnSwipeout, TcnSwipeoutAction } from '@/components/ui/swipeout';

<TcnSwipeout
  trailing={<TcnSwipeoutAction variant="destructive" onClick={remove}>Delete</TcnSwipeoutAction>}
>
  <div className="row">Meeting notes</div>
</TcnSwipeout>
<script setup lang="ts">
import { TcnSwipeout, TcnSwipeoutAction } from '@/components/ui/swipeout';
</script>

<template>
  <TcnSwipeout>
    <template #trailing>
      <TcnSwipeoutAction variant="destructive" @click="remove">Delete</TcnSwipeoutAction>
    </template>
    <div class="row">Meeting notes</div>
  </TcnSwipeout>
</template>

All gesture behaviour — pointer tracking, the translateX writes, snap thresholds, the single-open registry and outside/scroll dismissal — lives in the engine (TcnSwipeoutDirective / useSwipeout); the copied component owns only markup and the sliding content layer. iOS full-swipe-to-trigger-the-first-action is not implemented — a future engine addition can layer it on without changing this markup.

Props

Swipeout

Prop Type Default Description
disabled boolean false Disable the gesture (an open row can still close).
leading / trailing (React) ReactNode Action buttons revealed on each side.
openedChange / onOpenChange 'leading' | 'trailing' | null Fired when the open side changes.

Swipeout Action

Prop Type Default Description
variant 'default' | 'destructive' 'default' Destructive tints the button red.
Source · Angular
export * from './tcn-swipeout';
import { Component, Directive, input } from '@angular/core';
import { TcnSwipeoutDirective } from '@touchcn/angular/swipeout';

export type TcnSwipeoutSide = 'leading' | 'trailing';
export type TcnSwipeoutActionVariant = 'default' | 'destructive';

/**
 * Swipe-to-reveal list row. Wraps the row content and projects
 * `tcn-swipeout-actions` panels revealed by dragging the content horizontally.
 * All gesture behaviour — pointer tracking, the `translateX` writes, snap
 * thresholds, the single-open registry and outside/scroll dismissal — lives in
 * the engine `TcnSwipeoutDirective` (attached here as a host directive); this
 * component owns only the markup and the `.tcn-swipeout-content` sliding layer.
 *
 * iOS full-swipe-to-trigger-the-first-action is not implemented (it needs the
 * engine to track a past-threshold velocity/commit and fire the action) — a
 * future engine addition can layer it on without changing this markup.
 */
@Component({
  selector: 'tcn-swipeout',
  hostDirectives: [{ directive: TcnSwipeoutDirective, inputs: ['disabled'], outputs: ['openedChange'] }],
  template: `
    <ng-content select="tcn-swipeout-actions" />
    <div class="tcn-swipeout-content"><ng-content /></div>
  `,
  host: { class: 'tcn-swipeout block' },
})
export class TcnSwipeout {}

/** A leading or trailing panel of action buttons projected into a `tcn-swipeout`. */
@Component({
  selector: 'tcn-swipeout-actions',
  template: '<ng-content />',
  host: {
    class: 'tcn-swipeout-actions',
    '[attr.data-side]': 'side()',
  },
})
export class TcnSwipeoutActions {
  readonly side = input<TcnSwipeoutSide>('trailing');
}

/** A styled action button for a swipeout panel; `variant="destructive"` for deletes. */
@Directive({
  selector: 'button[tcnSwipeoutAction]',
  host: {
    class: 'tcn-swipeout-action',
    type: 'button',
    '[attr.data-variant]': 'variant()',
  },
})
export class TcnSwipeoutAction {
  readonly variant = input<TcnSwipeoutActionVariant>('default');
}
Source · React
export * from './tcn-swipeout';
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import { cn } from '@touchcn/core';
import { useSwipeout } from '@touchcn/react';
import type { TcnSwipeoutSide } from '@touchcn/react';

export type TcnSwipeoutActionVariant = 'default' | 'destructive';

export interface TcnSwipeoutProps {
  /** Action buttons revealed by swiping right (panel pinned to the left edge). */
  leading?: ReactNode;
  /** Action buttons revealed by swiping left (panel pinned to the right edge). */
  trailing?: ReactNode;
  /** Disables the gesture entirely (a currently open row can still be closed). */
  disabled?: boolean;
  /** Notified when the open side changes (`null` when it snaps closed). */
  onOpenChange?: (side: TcnSwipeoutSide | null) => void;
  children?: ReactNode;
  className?: string;
}

/**
 * Swipe-to-reveal list row. Wraps the row content and reveals `leading` /
 * `trailing` action panels by dragging the content horizontally. All gesture
 * behaviour — pointer tracking, the `translateX` writes, snap thresholds, the
 * single-open registry and outside/scroll dismissal — lives in the engine
 * `useSwipeout` hook; this component owns only the markup and the
 * `.tcn-swipeout-content` sliding layer, emitting the same `tcn-*` classes and
 * `data-*` attributes as the Angular component.
 *
 * iOS full-swipe-to-trigger-the-first-action is not implemented (it needs the
 * engine to track a past-threshold commit and fire the action) — a future
 * engine addition can layer it on without changing this markup.
 */
export function TcnSwipeout({ leading, trailing, disabled, onOpenChange, children, className }: TcnSwipeoutProps) {
  const { rootRef, state } = useSwipeout({ disabled, onOpenChange });

  return (
    <div ref={rootRef} className={cn('tcn-swipeout block', className)} data-state={state}>
      {leading && (
        <div className="tcn-swipeout-actions" data-side="leading">
          {leading}
        </div>
      )}
      {trailing && (
        <div className="tcn-swipeout-actions" data-side="trailing">
          {trailing}
        </div>
      )}
      <div className="tcn-swipeout-content">{children}</div>
    </div>
  );
}

export interface TcnSwipeoutActionProps extends ComponentPropsWithoutRef<'button'> {
  variant?: TcnSwipeoutActionVariant;
}

/** A styled action button for a swipeout panel; `variant="destructive"` for deletes. */
export function TcnSwipeoutAction({ variant = 'default', className, children, ...props }: TcnSwipeoutActionProps) {
  return (
    <button type="button" className={cn('tcn-swipeout-action', className)} data-variant={variant} {...props}>
      {children}
    </button>
  );
}
Source · Vue
<script setup lang="ts">
import { useSwipeout } from '@touchcn/vue';
import type { TcnSwipeoutSide } from '@touchcn/vue';

/**
 * Swipe-to-reveal list row. Wraps the row content and reveals `leading` /
 * `trailing` action panels (named slots) by dragging the content horizontally.
 * All gesture behaviour — pointer tracking, the `translateX` writes, snap
 * thresholds, the single-open registry and outside/scroll dismissal — lives in
 * the engine `useSwipeout` composable; this component owns only the markup and
 * the `.tcn-swipeout-content` sliding layer, emitting the same `tcn-*` classes
 * and `data-*` attributes across frameworks.
 *
 * iOS full-swipe-to-trigger-the-first-action is not implemented (it needs the
 * engine to track a past-threshold commit and fire the action) — a future engine
 * addition can layer it on without changing this markup.
 */
const props = withDefaults(defineProps<{ disabled?: boolean }>(), { disabled: false });

const emit = defineEmits<{ 'open-change': [side: TcnSwipeoutSide | null] }>();

const { setRoot, state } = useSwipeout({
  disabled: () => props.disabled,
  onOpenChange: (side) => emit('open-change', side),
});
</script>

<template>
  <div :ref="setRoot" class="tcn-swipeout block" :data-state="state">
    <div v-if="$slots.leading" class="tcn-swipeout-actions" data-side="leading">
      <slot name="leading" />
    </div>
    <div v-if="$slots.trailing" class="tcn-swipeout-actions" data-side="trailing">
      <slot name="trailing" />
    </div>
    <div class="tcn-swipeout-content"><slot /></div>
  </div>
</template>
<script lang="ts">
export type TcnSwipeoutActionVariant = 'default' | 'destructive';
</script>

<script setup lang="ts">
/** A styled action button for a swipeout panel; `variant="destructive"` for deletes. */
withDefaults(defineProps<{ variant?: TcnSwipeoutActionVariant }>(), { variant: 'default' });
</script>

<template>
  <button type="button" class="tcn-swipeout-action" :data-variant="variant"><slot /></button>
</template>
export { default as TcnSwipeout } from './TcnSwipeout.vue';
export { default as TcnSwipeoutAction } from './TcnSwipeoutAction.vue';
export type { TcnSwipeoutActionVariant } from './TcnSwipeoutAction.vue';

Last updated on July 24, 2026

Was this page helpful?