A headless React swipeable cards carousel component, similar to Tinder, with no runtime dependencies. Drag, fling, or programmatically swipe cards left, right, up or down. Cards can be discarded off-screen or sent to the back of the stack to loop forever. Built with the Web Animations API for smooth, momentum-aware animations and pivot-weighted rotation that feels natural under the finger.
npm install @daformat/react-swipeable-cardsyarn add @daformat/react-swipeable-cardspnpm add @daformat/react-swipeable-cardsbun add @daformat/react-swipeable-cardsdeno add npm:@daformat/react-swipeable-cardshttps://hello-mat.com/design-engineering/component/swipeable-cards
import { SwipeableCards } from "@daformat/react-swipeable-cards";
const cards = [
{ id: "1", card: <div className="card">Card 1</div> },
{ id: "2", card: <div className="card">Card 2</div> },
{ id: "3", card: <div className="card">Card 3</div> },
];
export function App() {
return (
<SwipeableCards.Root
cards={cards}
emptyView={<div>No more cards</div>}
onSwipe={(direction, cardId) => {
console.log(`Swiped ${cardId} ${direction}`);
}}
>
<SwipeableCards.Cards />
<SwipeableCards.SwipeLeftButton>👎</SwipeableCards.SwipeLeftButton>
<SwipeableCards.SwipeRightButton>👍</SwipeableCards.SwipeRightButton>
</SwipeableCards.Root>
);
}The component is headless: nothing is styled out of the box. You decide how cards look, you decide how the stack is laid out (typically with CSS custom properties exposed by the component, see CSS custom properties).
Given the following jsx:
// full source: https://github.com/daformat/hello-mat/blob/master/pages/design-engineering/component/swipeable-cards.tsx
<SwipeableCards.Root
cards={[...cards /* omitted for brevity */]}
className={styles.cards_root}
data-style={"stacked-offset" /* "stacked-rotation" | "minimal" */}
swipeStyle={"sendToBack"}
sendToBackMargin={16}
loop
>
<SwipeableCards.Cards
visibleStackLength={4}
style={{ aspectRatio: "650 / 400" }}
/>
</SwipeableCards.Root>Here is how I styled it in the demo
/* full source: https://github.com/daformat/hello-mat/blob/master/components/SwipeableCards/SwipeableCards.module.scss */
.cards_root {
--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
[data-swipeable-cards-cards] {
display: grid;
grid-template-columns: 1fr;
place-content: center;
position: relative;
touch-action: none;
z-index: 1;
[data-swipeable-cards-card-wrapper] {
align-content: center;
align-self: center;
grid-area: 1 / 1;
opacity: calc(
1 - clamp(0, var(--stack-index0) - var(--visible-stack-length), 1)
);
touch-action: none;
transform-origin: center 0;
transition: all 0.2s var(--ease-out-cubic);
transition-property: opacity, scale, padding-top, margin-top;
width: 100%;
will-change: opacity, scale, padding-top, margin-top, transform;
}
}
&:is([data-style="stacked-offset"], [data-style="stacked-rotation"]) {
[data-swipeable-cards-cards] {
[data-swipeable-cards-card-wrapper] {
scale: calc(
100% - min(var(--stack-index0), var(--visible-stack-length)) * 10%
);
}
}
}
&[data-style="stacked-offset"] {
[data-swipeable-cards-cards] {
[data-swipeable-cards-card-wrapper] {
--p: calc(
var(--card-top-distance, 0) *
max(var(--visible-stack-length) - var(--stack-index0), 0)
);
--m: calc(
var(--card-top-distance, 0) *
(
var(--visible-stack-length) - max(
var(--visible-stack-length) - var(--stack-index0),
0
)
)
);
margin-top: calc(var(--m) * -1);
padding-top: calc(var(--p));
}
}
}
}/* Provides context to all swipeable card components */
<SwipeableCards.Root cards={cards} emptyView={...}>
{/* The stack container */}
<SwipeableCards.Cards>
{/* Optional: bring your own renderer; otherwise the default renders one CardWrapper per card */}
{(stack) =>
stack.map((card) => (
<SwipeableCards.CardWrapper key={card.id} card={card} />
))
}
</SwipeableCards.Cards>
{/* Optional programmatic swipe buttons */}
<SwipeableCards.SwipeLeftButton />
<SwipeableCards.SwipeRightButton />
<SwipeableCards.SwipeUpButton />
<SwipeableCards.SwipeDownButton />
</SwipeableCards.Root>The outermost wrapper. Provides context, sets up the drag state, and listens
to pointermove / pointerup on the document. Renders a <div>.
The loop prop is part of a discriminated union: when loop is false (or
omitted) you must pass emptyView; when loop is true you must not
pass emptyView.
| Prop | Type | Default | Description |
|---|---|---|---|
cards (required) |
CardWithId[] |
— | Initial cards in the stack. The last item is the top card. |
emptyView |
ReactNode |
— | Required when loop is false. Displayed when the stack is empty. |
loop |
boolean |
false |
When true, swiped cards animate to the back of the stack instead of being removed. emptyView cannot be set in this mode. |
onSwipe |
(direction: SwipeDirection, cardId: string) => void |
— | Called once per swipe, immediately after the card commits to flying out (before the animation finishes). |
swipeStyle |
"discard" | "sendToBack" |
"discard" |
"discard" flings the card off-screen and fades it out. "sendToBack" slides it just past the stack edge before animating it under the other cards. |
sendToBackMargin |
number |
0 |
Extra pixels the card travels past the stack edge before being sent to the back. Only used when swipeStyle is "sendToBack". |
getCardElement |
(element: Element) => Element |
first child | Receives the wrapper element and returns the actual card element to use for collision detection. Defaults to element.firstElementChild ?? element, so by default any padding on the wrapper is excluded. Override when the visible card isn't the wrapper's first child (e.g. when you nest extra layout elements inside the wrapper). |
ref |
Ref<HTMLDivElement> |
— | Forwarded ref to the root <div>. |
...props |
ComponentPropsWithoutRef<"div"> |
— | All standard <div> props (className, style, children, etc.). |
| Attribute | Values | Description |
|---|---|---|
data-swipeable-cards-root |
"" |
Always present. Marks the root for context lookups. |
The stack container. Sets CSS custom properties used by your card styles to
compute z-index, scale, opacity, etc. for stacked cards. Renders a <div>
plus an inner <div data-empty> that holds the empty view when the stack is
empty (the empty view is always rendered, so you can transition it in/out
freely).
| Prop | Type | Default | Description |
|---|---|---|---|
visibleStackLength |
number |
4 |
How many cards from the top of the stack should appear in the visible peek. Used to compute --visible-stack-length for your styles. |
cardsTopDistance |
string |
"clamp(16px, 1vw, 32px)" |
Vertical distance between cards in the visible peek. Exposed as --card-top-distance so you can use it in top, padding-top, etc. |
children |
ReactNode | (stack: CardWithId[]) => ReactNode |
renders one wrapper per card | Either pre-rendered children or a function that receives the current stack and returns nodes. Use the function form when you need per-card customization. |
ref |
Ref<HTMLDivElement> |
— | Forwarded ref to the cards <div>. |
...props |
Omit<ComponentPropsWithoutRef<"div">, "children"> |
— | All standard <div> props except children (overridden by the function-as-children variant). |
| Attribute | Values | Description |
|---|---|---|
data-swipeable-cards-cards |
"" |
Always present. Used internally to locate the stack. |
data-empty |
"" |
Set on the inner wrapper that holds the empty view. |
A wrapper around a single card. Owns the pointer interactions for that card —
only the top card responds to pointerdown. Renders a <div> containing
either card.card or, if you pass children, your own content.
| Prop | Type | Default | Description |
|---|---|---|---|
card (required) |
CardWithId |
— | The { id, card } entry from the stack this wrapper represents. |
children |
ReactNode |
card.card |
Override the card's own JSX. Useful for layering controls on top of the card while keeping the wrapper handling pointer events. |
ref |
Ref<HTMLDivElement> |
— | Forwarded ref to the wrapper <div>. |
...props |
ComponentPropsWithoutRef<"div"> |
— | All standard <div> props. onPointerDown and onDragStart are merged with internal handlers (yours runs after). |
| Attribute | Values | Description |
|---|---|---|
data-swipeable-cards-card-wrapper |
"" |
Always present. Used internally to enumerate stack items. |
data-swipeable-cards-id |
the card.id |
Lets you target a specific card from CSS or query selectors. |
data-swipeable-cards-top-card |
"true" | "false" |
"true" when this wrapper is currently the top card. Useful for highlighting the actionable card with an attribute selector. |
Programmatic swipe buttons. Clicking one triggers a swipe of the current top
card in that direction with a randomized pivot, velocity, and rotation, so
repeated clicks feel natural. Automatically no-op when the stack is empty or
a swipe is already in progress. Render a <button>.
| Prop | Type | Default | Description |
|---|---|---|---|
onClick |
MouseEventHandler<HTMLButtonElement> |
— | Called synchronously before the swipe is triggered. Useful for analytics, sound effects, or syncing companion UI animations. |
ref |
Ref<HTMLButtonElement> |
— | Forwarded ref to the <button>. |
...props |
ComponentPropsWithoutRef<"button"> |
— | All standard <button> props. |
Returns the full internal context. Must be used inside SwipeableCards.Root.
const {
loop,
emptyView,
cards,
stack,
setStack,
discardedCardId,
setDiscardedCardId,
dragStateRef,
animationRef,
commitSwipe,
swipeStyle,
getCardElement,
sendToBackMargin,
rootRef,
} = SwipeableCards.useSwipeableCardsContext();| Property | Type | Description |
|---|---|---|
loop |
boolean | undefined |
Reflects the loop prop passed to Root. |
emptyView |
ReactNode |
The empty view passed to Root (only set when loop is false). |
cards |
CardWithId[] |
The original cards prop passed to Root. Use stack for the live, mutated list. |
stack |
CardWithId[] |
The current stack. Cards are removed (or rotated, when looping) as they are swiped. |
setStack |
Dispatch<SetStateAction<CardWithId[]>> |
Imperatively replace the stack. Useful for adding new cards on demand. |
discardedCardId |
string |
The id of the card currently animating out, or "" if no swipe is in progress. |
setDiscardedCardId |
Dispatch<SetStateAction<string>> |
Setter for the above (rarely needed externally). |
dragStateRef |
RefObject<DraggingState> |
The mutable drag state (pointer position, velocity, pivot, currently-dragged element). Read it during a drag for custom UI feedback. |
animationRef |
RefObject<Animation[]> |
The Web Animations API animations currently running for the active swipe. |
commitSwipe |
(manual?: boolean, event?: PointerEvent) => void |
Commits the current drag state into a swipe (or returns the card to the stack if the gesture is too small). |
swipeStyle |
"discard" | "sendToBack" |
Reflects the swipeStyle prop. |
getCardElement |
(element: Element) => Element |
Reflects the getCardElement prop. |
sendToBackMargin |
number |
Reflects the sendToBackMargin prop. |
rootRef |
RefObject<HTMLDivElement> |
Ref to the root <div> rendered by SwipeableCards.Root. |
Convenience hook that returns just the current stack (memoized). Use this
in components that only care about the stack, to avoid re-rendering on
unrelated context changes.
const stack = SwipeableCards.useSwipeableCardsStack();Powers the built-in directional buttons. Use it to build your own swipe
triggers. Throws if the surrounding tree does not include a
SwipeableCards.Cards container when the trigger fires.
const { trigger, swipeStyle } = SwipeableCards.useProgrammaticSwipe();| Property | Type | Description |
|---|---|---|
trigger |
(configure: (state: DraggingState, rect: DOMRect) => void) => void |
Triggers a swipe of the top card. Your configure callback receives the (zeroed) drag state and the wrapper's bounding rect; set velocityX/Y, pivotX/Y, startX/Y, lastX/Y. |
swipeStyle |
"discard" | "sendToBack" |
Mirrors the active swipeStyle so your custom button can pick velocity values that look right for either mode. |
function SuperLikeButton() {
const { trigger } = SwipeableCards.useProgrammaticSwipe();
return (
<button
type="button"
onClick={() =>
trigger((state, rect) => {
state.velocityX = 0;
state.velocityY = -(rect.height / 442) * 4;
state.pivotX = 0;
state.pivotY = -0.4;
state.startY = 0;
state.lastY = -1;
})
}
>
⭐
</button>
);
}The component sets these CSS custom properties so you can fully drive the visual stack appearance from your own CSS, without prop-drilling values into every card.
| Property | Description |
|---|---|
--stack-length |
Number of cards currently in the stack. |
--visible-stack-length |
Number of cards in the "peek" (top of the stack visible behind the topmost card), capped to visibleStackLength. |
--card-top-distance |
The cardsTopDistance value, ready to be used in top, padding-top, etc. |
| Property | Description |
|---|---|
--stack-index |
1-based index of the card from the top. The top card is 1. |
--stack-index0 |
0-based index of the card from the top. The top card is 0. |
[data-swipeable-cards-card-wrapper] {
position: absolute;
inset: 0;
transform-origin: center top;
transition:
scale 0.3s,
translate 0.3s;
scale: calc(1 - var(--stack-index0) * 0.05);
translate: 0 calc(var(--stack-index0) * var(--card-top-distance));
z-index: var(--stack-length);
pointer-events: none;
}
[data-swipeable-cards-top-card="true"] {
pointer-events: auto;
cursor: grab;
}
[data-swipeable-cards-top-card="true"]:active {
cursor: grabbing;
}import type {
CardWithId,
SwipeDirection,
SwipeStyle,
GetCardElement,
StackRenderer,
DraggingState,
BaseSwipeableCardsProps,
NotLoopingSwipeableProps,
LoopingSwipeableProps,
SwipeableCardsProps,
SwipeableCardsCardsProps,
SwipeableCardsCardWrapperProps,
} from "@daformat/react-swipeable-cards";| Type | Shape |
|---|---|
CardWithId |
{ id: string; card: JSX.Element } |
SwipeDirection |
"left" | "right" | "up" | "down" |
SwipeStyle |
"discard" | "sendToBack" |
GetCardElement |
(element: Element) => Element |
StackRenderer |
(stack: CardWithId[]) => ReactNode |
DraggingState |
Mutable drag state object (pointer position, velocity, pivot, currently-dragged element). See dragStateRef above. |
SwipeableCardsProps |
Discriminated union of NotLoopingSwipeableProps and LoopingSwipeableProps. |