Swipe

A row that slides aside to reveal actions behind it — delete, archive, snooze, mark done.

Open Source

Installation

Swipe ships with the library — install it once, then import:

import { Swipe } from "@delacour/react-native-ui/swipe";

Or copy the source into your project, to own and edit it:

bunx delacour@latest add swipe

Usage

app/index.tsx
import { IconTrashCan } from "@/lib/icons/central";
import { Item } from "@/components/ui/item";
import { Swipe } from "@/components/ui/swipe";
import { useState } from "react";

export default function Example() {
  const [isDeleted, setIsDeleted] = useState(false);

  if (isDeleted) return null;

  return (
    <Swipe haptic="selection">
      <Swipe.End>
        <Swipe.Action color="destructive" icon={IconTrashCan} label="Delete" onPress={() => setIsDeleted(true)} />
      </Swipe.End>
      <Item>
        <Item.Content>
          <Item.Title>Invoice #2041</Item.Title>
        </Item.Content>
      </Item>
    </Swipe>
  );
}
Swipe to delete

Anatomy

<Swipe.Group>
  <Swipe>
    <Swipe.Start>
      <Swipe.Action />
    </Swipe.Start>
    <Swipe.End>
      <Swipe.Action />
      <Swipe.Action />
    </Swipe.End>
    <Item />
  </Swipe>
</Swipe.Group>

Swipe.Start and Swipe.End are markers. The root lifts their actions out and lays them out itself, because only the root knows how wide the gap behind the row is. Everything else in children is the row, and order does not matter.

The actions sit behind the row rather than beside it, so nothing reflows when one opens, and only the row moves, on the UI thread. The panel behind it is exactly as wide as the gap the row has left, so it never paints over the row and the row needs no background of its own.

Both sides

end is the edge text runs toward, so sliding the row toward the start reveals Swipe.End. Under right to left the sides mirror with no change to your code.

Both sides
<Swipe>
  <Swipe.Start>
    <Swipe.Action color="success" icon={IconCheckmark2} label="Done" onPress={complete} />
  </Swipe.Start>
  <Swipe.End>
    <Swipe.Action color="warning" icon={IconBell2Snooze} label="Snooze" onPress={snooze} />
    <Swipe.Action color="destructive" icon={IconTrashCan} label="Delete" onPress={remove} />
  </Swipe.End>
  <Item>…</Item>
</Swipe>

A side with no actions does not open: the row follows the finger a little, then returns.

Colours

default, primary, success, warning, info and destructive. A tile draws on bg-{color} with its glyph and caption in {color}-foreground; default is bg-muted.

Full swipe

Drag well past the actions and release, and the outermost action fires — the last one on end, the first on start — while the row is carried off. Past the tiles, that outermost tile grows to fill the overshoot, so the gesture reads as more of that action rather than an empty strip. It is on by default; isFullSwipe={false} turns it off.

The finger itself has to be past the point, not just a fast flick, because the outermost action is often Delete. haptic ticks once as the drag crosses it, and again if it crosses back.

<Swipe haptic="selection" isFullSwipe>
  …
</Swipe>

Keeping the row open

A tap on a tile runs its onPress and closes the row. isKeptOpen leaves it open — for a toggle such as a bookmark, whose new state the reader wants to see. Tapping the open row closes it without passing the tap through.

Kept open
<Swipe.Action
  color="primary"
  icon={isSaved ? IconBookmarkCheck : IconBookmark}
  isKeptOpen
  label={isSaved ? "Saved" : "Save"}
  onPress={() => setIsSaved((value) => !value)}
/>

One open at a time

Swipe.Group keeps a single row open: opening one closes the rest. Rows register with the group through context, so a row nested inside a ListGroup, a .map() or a component of your own still belongs. useSwipeGroup().closeAll() closes every row — outside a group it does nothing, so a screen can call it either way.

One open at a time
<Swipe.Group>
  <ListGroup>
    {reminders.map((reminder) => (
      <Swipe key={reminder.id}>…</Swipe>
    ))}
  </ListGroup>
</Swipe.Group>

From code

A ref exposes open(side) and close(). onOpenChange reports the side that settled open, or null once the row is shut.

Imperative
const swipe = useRef<SwipeHandle>(null);

<Swipe onOpenChange={setOpenSide} ref={swipe}>…</Swipe>
<Button onPress={() => swipe.current?.open("end")}>
  <Button.Label>Show actions</Button.Label>
</Button>

Sharing the screen with a scroller

The row claims the touch only once a drag is clearly sideways (10pt horizontally) and gives it up as soon as it commits vertically (8pt), so a list of rows scrolls as it would without them. Nothing re-renders during a drag. Close open rows before the list moves out from under them — call useSwipeGroup().closeAll() from the list's onScrollBeginDrag, or when the screen loses focus.

Accessibility

A swipe is invisible to a screen reader, so the row publishes every action as an accessibility action, named by its label; VoiceOver offers them from the actions rotor and TalkBack from its actions menu. That is why label is required even on a tile whose glyph says it all. The tiles are buttons, and they are hidden from assistive technology while the row is shut. Under reduced motion every spring becomes a short timing and a full swipe fades the row instead of carrying it off.

API Reference

Swipe

Extends React Native's ViewProps.

Prop

Type

Swipe.Start / Swipe.End

Markers. Their children are the side's Swipe.Actions, in visual order.

Swipe.Action

Prop

Type

Swipe.Group

Extends ViewProps.

Prop

Type

useSwipe

Returns { openSide, open, close } for a custom child of the row. Throws outside a <Swipe>.

useSwipeGroup

Returns { closeAll }. Outside a Swipe.Group it is a no-op.

On this page