diff --git a/.changeset/interactive-card-content.md b/.changeset/interactive-card-content.md
new file mode 100644
index 0000000..f3ad171
--- /dev/null
+++ b/.changeset/interactive-card-content.md
@@ -0,0 +1,13 @@
+---
+'@react-native-motion-kit/swipe-deck': minor
+---
+
+Add `interactive` to `SwipeDeck.Card` so active-card children such as CTA buttons or links can receive touches without exposing raw React Native `pointerEvents`.
+
+```tsx
+ item.type === 'ad'}>
+ {({ item }) => openAd(item)} />}
+
+```
+
+Only the active card can become interactive. Background cards remain non-interactive to preserve swipe-deck safety.
diff --git a/docs/docs/1.x/en/guide/usage/api-reference.mdx b/docs/docs/1.x/en/guide/usage/api-reference.mdx
index 461caa0..82bb6f2 100644
--- a/docs/docs/1.x/en/guide/usage/api-reference.mdx
+++ b/docs/docs/1.x/en/guide/usage/api-reference.mdx
@@ -69,10 +69,11 @@ Static `SwipeDeck.Root` accepts the same props except `id`.
## `SwipeDeck.Card` Props
-| Prop | Type | Notes |
-| ---------- | ----------------------------------------- | --------------------------------------- |
-| `style` | `StyleProp` | Style applied to the absolute card. |
-| `children` | `(info: SwipeRenderInfo) => ReactNode` | Renders one item in the bounded window. |
+| Prop | Type | Notes |
+| ------------- | ---------------------------------------------------- | --------------------------------------------------- |
+| `interactive` | `boolean \| ((info: SwipeRenderInfo) => boolean)` | Enables touchable children on the active card only. |
+| `style` | `StyleProp` | Style applied to the absolute card. |
+| `children` | `(info: SwipeRenderInfo) => ReactNode` | Renders one item in the bounded window. |
`SwipeRenderInfo` includes `item`, `index`, `offset`, `role`, and
`isActive`.
diff --git a/docs/docs/1.x/en/guide/usage/basic-usage.mdx b/docs/docs/1.x/en/guide/usage/basic-usage.mdx
index 9687430..f4cc082 100644
--- a/docs/docs/1.x/en/guide/usage/basic-usage.mdx
+++ b/docs/docs/1.x/en/guide/usage/basic-usage.mdx
@@ -44,6 +44,29 @@ repeating generics in JSX.
```
+## Interactive Card Content
+
+Cards are non-interactive by default so the deck can own swipe gestures safely.
+Use `interactive` when the active card contains touchable children such as CTA buttons or links.
+
+```tsx
+
+ {({ item }) => openAd(item)} />}
+
+```
+
+Use the predicate form when only some items should expose touchable children.
+
+```tsx
+ item.type === 'ad'}>
+ {({ item }) => }
+
+```
+
+`interactive` only affects the active card. Background cards remain non-interactive.
+For deeper nested gestures such as card-local carousels or scroll views, prefer a dedicated
+future gesture-coordination API instead of exposing raw `pointerEvents`.
+
## Allowed Directions
`allowedDirections` limits which directions can be accepted as a dismiss.
diff --git a/docs/docs/1.x/ko/guide/usage/api-reference.mdx b/docs/docs/1.x/ko/guide/usage/api-reference.mdx
index 7fd5c75..86d0e6b 100644
--- a/docs/docs/1.x/ko/guide/usage/api-reference.mdx
+++ b/docs/docs/1.x/ko/guide/usage/api-reference.mdx
@@ -69,10 +69,11 @@ Static `SwipeDeck.Root`는 `id`를 제외한 같은 props를 받습니다.
## `SwipeDeck.Card` props
-| Prop | Type | 설명 |
-| ---------- | ----------------------------------------- | ------------------------------------------ |
-| `style` | `StyleProp` | Absolute card container에 적용됩니다. |
-| `children` | `(info: SwipeRenderInfo) => ReactNode` | Bounded window의 item 하나를 렌더링합니다. |
+| Prop | Type | 설명 |
+| ------------- | ---------------------------------------------------- | --------------------------------------------------- |
+| `interactive` | `boolean \| ((info: SwipeRenderInfo) => boolean)` | Active card 안의 touchable children을 활성화합니다. |
+| `style` | `StyleProp` | Absolute card container에 적용됩니다. |
+| `children` | `(info: SwipeRenderInfo) => ReactNode` | Bounded window의 item 하나를 렌더링합니다. |
`SwipeRenderInfo`는 `item`, `index`, `offset`, `role`, `isActive`를 포함합니다.
diff --git a/docs/docs/1.x/ko/guide/usage/basic-usage.mdx b/docs/docs/1.x/ko/guide/usage/basic-usage.mdx
index 9c78909..7639987 100644
--- a/docs/docs/1.x/ko/guide/usage/basic-usage.mdx
+++ b/docs/docs/1.x/ko/guide/usage/basic-usage.mdx
@@ -44,6 +44,29 @@ Factory는 `Root`, `Card`, hook이 같은 item type을 공유하도록 해 JSX
```
+## Interactive Card Content
+
+Card는 기본적으로 non-interactive입니다. 그래야 deck이 swipe gesture를 안전하게 소유할 수 있습니다.
+Active card 안에 CTA button이나 link 같은 touchable children이 있다면 `interactive`를 사용하세요.
+
+```tsx
+
+ {({ item }) => openAd(item)} />}
+
+```
+
+일부 item에서만 touchable children을 열어야 한다면 predicate form을 사용하세요.
+
+```tsx
+ item.type === 'ad'}>
+ {({ item }) => }
+
+```
+
+`interactive`는 active card에만 적용됩니다. 뒤쪽 card는 계속 non-interactive입니다.
+Card 내부 carousel이나 scroll view처럼 더 깊은 nested gesture 조정은 raw `pointerEvents`를
+노출하기보다 별도의 gesture-coordination API로 다루는 것이 좋습니다.
+
## Allowed Directions
`allowedDirections`는 dismiss로 받아들일 방향을 제한합니다.
diff --git a/src/__tests__/SwipeDeck.integration.test.tsx b/src/__tests__/SwipeDeck.integration.test.tsx
index 5e72f59..93785b3 100644
--- a/src/__tests__/SwipeDeck.integration.test.tsx
+++ b/src/__tests__/SwipeDeck.integration.test.tsx
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import { Pressable, Text, View } from 'react-native';
import { fireGestureHandler, getByGestureTestId } from 'react-native-gesture-handler/jest-utils';
-import type { SwipeDirection } from '../index';
+import type { SwipeDeckCardInteractive, SwipeDirection } from '../index';
import { createSwipeDeck, SwipeDeck, SwipeDeckActionMotion, SwipeDeckUndoMotion } from '../index';
@@ -365,6 +365,107 @@ describe('SwipeDeck factory hooks', () => {
expect(getVisibleProfileKey).toHaveBeenCalled();
});
+ it('enables touchable children only for interactive active cards', async () => {
+ const ProfileDeck = createSwipeDeck();
+
+ function expectCardPointerEvents(current: 'box-none' | 'none', next: 'none') {
+ expect(screen.getByTestId('swipe-deck-card-current')).toHaveStyle({
+ pointerEvents: current,
+ });
+ expect(screen.getByTestId('swipe-deck-card-next')).toHaveStyle({
+ pointerEvents: next,
+ });
+ }
+
+ function Example({
+ interactive,
+ styledPointerEvents = false,
+ visibleProfiles = profiles,
+ }: {
+ interactive?: SwipeDeckCardInteractive;
+ styledPointerEvents?: boolean;
+ visibleProfiles?: readonly Profile[];
+ }) {
+ return (
+
+
+ {({ item }) => {item.name}}
+
+
+ );
+ }
+
+ const renderResult = await render();
+
+ expectCardPointerEvents('none', 'none');
+
+ await renderResult.rerender();
+
+ expectCardPointerEvents('box-none', 'none');
+
+ await renderResult.rerender();
+
+ expectCardPointerEvents('none', 'none');
+
+ await renderResult.rerender();
+
+ expectCardPointerEvents('box-none', 'none');
+
+ await renderResult.rerender( item.id === 'ada'} />);
+
+ expectCardPointerEvents('box-none', 'none');
+
+ await renderResult.rerender(
+ item.id === 'ada'}
+ visibleProfiles={[graceProfile, adaProfile]}
+ />,
+ );
+
+ expectCardPointerEvents('none', 'none');
+ });
+
+ it('allows nested pressables only when the active card is interactive', async () => {
+ const ProfileDeck = createSwipeDeck();
+ const onOpenProfile = jest.fn();
+ const user = userEvent.setup();
+
+ function Example({ interactive = false }: { interactive?: boolean }) {
+ return (
+
+
+ {({ item }) => (
+ onOpenProfile(item.id)}
+ >
+ {item.name}
+
+ )}
+
+
+ );
+ }
+
+ const renderResult = await render();
+
+ await user.press(screen.getByRole('button', { name: 'Open Ada' }));
+
+ expect(onOpenProfile).not.toHaveBeenCalled();
+
+ await renderResult.rerender();
+
+ await user.press(screen.getByRole('button', { name: 'Open Ada' }));
+ await user.press(screen.getByRole('button', { name: 'Open Grace' }));
+
+ expect(onOpenProfile).toHaveBeenCalledTimes(1);
+ expect(onOpenProfile).toHaveBeenCalledWith('ada');
+ });
+
it('updates action gating when disabled changes after mount', async () => {
const ProfileDeck = createSwipeDeck();
const user = userEvent.setup();
diff --git a/src/components/SwipeDeckRenderedCard.tsx b/src/components/SwipeDeckRenderedCard.tsx
index b2e39dd..c2f47a1 100644
--- a/src/components/SwipeDeckRenderedCard.tsx
+++ b/src/components/SwipeDeckRenderedCard.tsx
@@ -1,4 +1,5 @@
import type { ReactElement } from 'react';
+import type { ViewStyle } from 'react-native';
import { Fragment } from 'react';
import { StyleSheet } from 'react-native';
@@ -7,7 +8,7 @@ import Animated, { type SharedValue, useAnimatedStyle } from 'react-native-reani
import type { SwipeDeckRenderedCardMotionConfig } from '../core/renderedCardMotionTypes';
import type { SwipeRenderTransition } from '../core/rendering';
import type { SwipeWindowDescriptor } from '../core/windowing';
-import type { SwipeDeckCardProps, SwipeRenderInfo } from '../types';
+import type { SwipeDeckCardInteractive, SwipeDeckCardProps, SwipeRenderInfo } from '../types';
import {
resolveSwipeDeckDragTranslateY,
@@ -17,6 +18,8 @@ import {
const STACK_TRANSFORM_ORIGIN: [string, string, number] = ['50%', '0%', 0];
+type SwipeDeckCardPointerEvents = NonNullable;
+
type SwipeDeckRenderedCardProps = {
itemIndex: number;
itemKey: string;
@@ -36,6 +39,32 @@ type SwipeDeckRenderedCardProps = {
motionConfig: SwipeDeckRenderedCardMotionConfig;
};
+function resolveSwipeDeckCardInteractive(
+ interactive: SwipeDeckCardInteractive | undefined,
+ renderInfo: SwipeRenderInfo,
+): boolean {
+ if (!renderInfo.isActive) {
+ return false;
+ }
+
+ if (typeof interactive === 'function') {
+ return interactive(renderInfo);
+ }
+
+ return interactive === true;
+}
+
+function resolveSwipeDeckCardPointerEvents(
+ interactive: SwipeDeckCardInteractive | undefined,
+ renderInfo: SwipeRenderInfo,
+): SwipeDeckCardPointerEvents {
+ if (resolveSwipeDeckCardInteractive(interactive, renderInfo)) {
+ return 'box-none';
+ }
+
+ return 'none';
+}
+
export function SwipeDeckRenderedCard({
itemIndex,
itemKey,
@@ -171,11 +200,16 @@ export function SwipeDeckRenderedCard({
const content = cardSlot.props.children(renderInfo);
const cardStyle = cardSlot.props.style;
+ const cardPointerEvents = resolveSwipeDeckCardPointerEvents(
+ cardSlot.props.interactive,
+ renderInfo,
+ );
+ const cardPointerEventsStyle =
+ cardPointerEvents === 'box-none' ? styles.interactiveCard : styles.nonInteractiveCard;
return (
{content}
@@ -191,4 +225,10 @@ const styles = StyleSheet.create({
bottom: 0,
left: 0,
},
+ interactiveCard: {
+ pointerEvents: 'box-none',
+ },
+ nonInteractiveCard: {
+ pointerEvents: 'none',
+ },
});
diff --git a/src/index.tsx b/src/index.tsx
index 5f92cbf..ecd261d 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -9,6 +9,7 @@ export type {
SwipeDeckActionMotionRecipe,
SwipeDeckActionSpringboardMotionOptions,
SwipeDeckActionSpringboardMotionRecipe,
+ SwipeDeckCardInteractive,
SwipeDeckCardProps,
SwipeDeckActions,
SwipeDeckEventHook,
diff --git a/src/types.ts b/src/types.ts
index debe10d..2331219 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -21,6 +21,8 @@ export type SwipeRenderInfo = {
isActive: boolean;
};
+export type SwipeDeckCardInteractive = boolean | ((info: SwipeRenderInfo) => boolean);
+
export type SwipeEvent = {
item: T;
index: number;
@@ -474,6 +476,13 @@ export type SwipeDeckProps = {
};
export type SwipeDeckCardProps = {
+ /**
+ * Allows touchable children such as buttons or links to receive touches on the active card.
+ *
+ * Background cards always remain non-interactive. Use the predicate form when only specific
+ * items, such as ad cards with CTA buttons, should expose touchable children.
+ */
+ interactive?: SwipeDeckCardInteractive;
/** Style applied to the absolute card container. */
style?: StyleProp;
/** Renders a card for one item in the bounded window. */
diff --git a/type-tests/useDeckEvent.tsx b/type-tests/useDeckEvent.tsx
index 6ed6098..38891e4 100644
--- a/type-tests/useDeckEvent.tsx
+++ b/type-tests/useDeckEvent.tsx
@@ -1,4 +1,9 @@
-import type { SwipeEvent, SwipeEventSource } from '../src';
+import type {
+ SwipeDeckCardInteractive,
+ SwipeEvent,
+ SwipeEventSource,
+ SwipeRenderInfo,
+} from '../src';
import { createSwipeDeck } from '../src';
@@ -12,6 +17,13 @@ const profile: Profile = { id: 'ada', name: 'Ada' };
function expectType(_value: T): void {}
+expectType>(true);
+expectType>((info) => {
+ expectType>(info);
+ expectType(info.item);
+
+ return info.isActive;
+});
expectType('gesture');
expectType('programmatic');
expectType | undefined>(ProfileDeck.useDeckEvent('swipe'));
@@ -47,4 +59,26 @@ const rootWithCallbackProp = (
);
+const booleanInteractiveCard = {() => null};
+const predicateInteractiveCard = (
+ {
+ expectType>(info);
+ expectType(info.item);
+
+ return info.item.id === profile.id;
+ }}
+ >
+ {() => null}
+
+);
+
+const invalidInteractiveCard = (
+ // @ts-expect-error Card interactive only accepts a boolean or typed predicate.
+ {() => null}
+);
+
+void booleanInteractiveCard;
+void predicateInteractiveCard;
+void invalidInteractiveCard;
void rootWithCallbackProp;