# Card Stack

A deck of cards the user swipes left or right one at a time, with buttons, undo and an empty state.

Card Stack is an open-source interaction from suiss/ui for React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/interactions/card-stack

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/interactions-card-stack--default.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { CardStack, CardStackActions, CardStackEmpty, CardStackSwipeButton, CardStackUndo } from "@/components/interactions/card-stack"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Text } from "@/components/ui/text"

type Place = { id: string; name: string; city: string; detail: string }

const places: Place[] = [
  {
    id: "harbor",
    name: "Harbor Café",
    city: "Lisbon",
    detail: "Slow mornings, strong coffee and a view of the river.",
  },
  {
    id: "garden",
    name: "Garden Library",
    city: "Kyoto",
    detail: "Quiet reading rooms around a moss garden.",
  },
  {
    id: "market",
    name: "Night Market",
    city: "Taipei",
    detail: "Street food stalls open until two in the morning.",
  },
]

function PlaceCard({ place }: { place: Place }) {
  return (
    <Card className="h-full">
      <CardHeader>
        <CardTitle>{place.name}</CardTitle>
        <CardDescription>{place.city}</CardDescription>
      </CardHeader>
      <CardContent className="grow">
        <View className="grow rounded-xl bg-muted" />
      </CardContent>
      <CardContent>
        <Text className="text-sm text-muted-foreground">{place.detail}</Text>
      </CardContent>
    </Card>
  )
}

function Demo({ disabled = false }: { disabled?: boolean }) {
  const [log, setLog] = React.useState("")
  return (
    <View className="gap-3">
      <CardStack
        data={places}
        keyExtractor={(place) => place.id}
        renderItem={(place) => <PlaceCard place={place} />}
        itemAccessibilityLabel={(place) => `${place.name}, ${place.city}`}
        disabled={disabled}
        onSwipe={(direction, place) =>
          setLog(`${direction === "left" ? "Skipped" : "Liked"} ${place.name}`)
        }
        onUndo={(place) => setLog(`Restored ${place.name}`)}
      >
        <CardStackEmpty>You have seen every place.</CardStackEmpty>
        <CardStackActions>
          <CardStackSwipeButton direction="left" />
          <CardStackUndo />
          <CardStackSwipeButton direction="right" />
        </CardStackActions>
      </CardStack>
      <Text role="status" className="text-center text-sm text-muted-foreground">
        {log}
      </Text>
    </View>
  )
}

export function CardStackExample() {
  return (
    <Demo />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct CardStackExample: View {
  var body: some View {
    CardStackDemo(places: cardStackPlaces)
  }
}

struct CardStackPlace: Identifiable {
  let id: String
  let name: String
  let city: String
  let detail: String
}

let cardStackPlaces = [
  CardStackPlace(id: "harbor", name: "Harbor Café", city: "Lisbon", detail: "Slow mornings, strong coffee and a view of the river."),
  CardStackPlace(id: "garden", name: "Garden Library", city: "Kyoto", detail: "Quiet reading rooms around a moss garden."),
  CardStackPlace(id: "market", name: "Night Market", city: "Taipei", detail: "Street food stalls open until two in the morning."),
]

struct CardStackPlaceCard: View {
  let place: CardStackPlace

  var body: some View {
    Card {
      CardHeader(LocalizedStringKey(place.name), description: LocalizedStringKey(place.city))
      CardContent {
        RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
          .fill(EsuissColor.muted)
          .frame(maxHeight: .infinity)
      }
      .frame(maxHeight: .infinity)
      CardContent {
        Text(verbatim: place.detail)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
      }
    }
    .frame(maxHeight: .infinity)
    .accessibilityElement(children: .combine)
    .accessibilityLabel(Text(verbatim: "\(place.name), \(place.city)"))
  }
}

struct CardStackDemo: View {
  let places: [CardStackPlace]
  var showsLog = true

  @State private var controller = CardStackController()
  @State private var log = ""

  var body: some View {
    VStack(spacing: 12) {
      VStack(spacing: 24) {
        ZStack {
          if !controller.canSwipe {
            Text("You have seen every place.")
              .textStyle(.sm)
              .foregroundStyle(EsuissColor.mutedForeground)
              .multilineTextAlignment(.center)
              .padding(24)
              .frame(maxWidth: .infinity, maxHeight: .infinity)
              .overlay {
                RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
                  .strokeBorder(EsuissColor.border, style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
              }
          }
          CardStack(
            places,
            controller: controller,
            onSwipe: { direction, place in
              log = "\(direction == .left ? "Skipped" : "Liked") \(place.name)"
            },
            onUndo: { place in
              log = "Restored \(place.name)"
            }
          ) { place in
            CardStackPlaceCard(place: place)
          }
        }
        .frame(height: 384)
        HStack(spacing: 16) {
          CardStackButton(.swipeLeft, controller: controller)
          CardStackButton(.undo, controller: controller)
          CardStackButton(.swipeRight, controller: controller)
        }
      }
      if showsLog {
        Text(verbatim: log)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
          .multilineTextAlignment(.center)
          .frame(maxWidth: .infinity)
          .accessibilityAddTraits(.updatesFrequently)
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ArrowCounterClockwise
import com.adamglin.phosphoricons.regular.Heart
import com.adamglin.phosphoricons.regular.X
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Card
import com.esuiss.ui.components.CardContent
import com.esuiss.ui.components.CardHeader
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.CardStack
import com.esuiss.ui.interactions.CardStackDirection
import com.esuiss.ui.interactions.CardStackState
import com.esuiss.ui.interactions.rememberCardStackState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

@Composable
fun CardStackExample() {
    CardStackDemo(enabled = true)
}

class CardStackPlace(val id: String, val name: String, val city: String, val detail: String)

val cardStackPlaces = listOf(
    CardStackPlace("harbor", "Harbor Café", "Lisbon", "Slow mornings, strong coffee and a view of the river."),
    CardStackPlace("garden", "Garden Library", "Kyoto", "Quiet reading rooms around a moss garden."),
    CardStackPlace("market", "Night Market", "Taipei", "Street food stalls open until two in the morning."),
)

val cardStackDeckHeight = 384.dp

@Composable
fun CardStackPlaceCard(place: CardStackPlace, modifier: Modifier = Modifier) {
    val colors = EsuissTheme.colors
    Card(modifier.fillMaxWidth().height(cardStackDeckHeight)) {
        CardHeader(place.name, description = place.city)
        CardContent(Modifier.weight(1f)) {
            Box(
                Modifier
                    .fillMaxSize()
                    .background(colors.muted, RoundedCornerShape(Radius.xl)),
            )
        }
        CardContent {
            Text(place.detail, style = Typography.sm.copy(color = colors.mutedForeground))
        }
    }
}

@Composable
fun CardStackEmptyBox() {
    val colors = EsuissTheme.colors
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(cardStackDeckHeight)
            .drawBehind {
                val stroke = 1.dp.toPx()
                val radius = Radius.xl2.toPx()
                drawRoundRect(
                    color = colors.border,
                    topLeft = Offset(stroke / 2, stroke / 2),
                    size = Size(size.width - stroke, size.height - stroke),
                    cornerRadius = CornerRadius(radius, radius),
                    style = Stroke(width = stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(3.dp.toPx(), 3.dp.toPx()))),
                )
            }
            .padding(24.dp),
        contentAlignment = Alignment.Center,
    ) {
        Text(
            "You have seen every place.",
            style = Typography.sm.copy(color = colors.mutedForeground),
            textAlign = TextAlign.Center,
        )
    }
}

@Composable
fun CardStackActions(state: CardStackState, enabled: Boolean) {
    val colors = EsuissTheme.colors
    val canSwipe = enabled && !state.isEmpty
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Button(
            onClick = { state.swipe(CardStackDirection.Left) },
            modifier = Modifier.size(56.dp),
            variant = ButtonVariant.Outline,
            size = ButtonSize.IconLg,
            enabled = canSwipe,
        ) {
            Icon(PhosphorIcons.Regular.X, contentDescription = "Swipe left", size = 24.dp, tint = colors.destructive)
        }
        Button(
            onClick = { state.undo() },
            variant = ButtonVariant.Ghost,
            size = ButtonSize.IconLg,
            enabled = state.canUndo,
        ) {
            Icon(PhosphorIcons.Regular.ArrowCounterClockwise, contentDescription = "Undo")
        }
        Button(
            onClick = { state.swipe(CardStackDirection.Right) },
            modifier = Modifier.size(56.dp),
            variant = ButtonVariant.Outline,
            size = ButtonSize.IconLg,
            enabled = canSwipe,
        ) {
            Icon(PhosphorIcons.Regular.Heart, contentDescription = "Swipe right", size = 24.dp, tint = colors.primary)
        }
    }
}

@Composable
fun CardStackDemo(enabled: Boolean) {
    var log by rememberSaveable { mutableStateOf("") }
    val state = rememberCardStackState()
    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Column(verticalArrangement = Arrangement.spacedBy(24.dp)) {
            CardStack(
                items = cardStackPlaces,
                onSwipe = { direction, place ->
                    log = "${if (direction == CardStackDirection.Left) "Skipped" else "Liked"} ${place.name}"
                },
                state = state,
                key = { it.id },
                enabled = enabled,
                onUndo = { _, place -> log = "Restored ${place.name}" },
                empty = { CardStackEmptyBox() },
            ) { place ->
                CardStackPlaceCard(place, Modifier.semantics(mergeDescendants = true) { contentDescription = "${place.name}, ${place.city}" })
            }
            CardStackActions(state, enabled)
        }
        Text(
            log,
            modifier = Modifier.fillMaxWidth().semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
            textAlign = TextAlign.Center,
        )
    }
}
```

`CardStack` shows the top card of `data` with up to `visibleCount` cards stacked behind it, each slightly smaller and lower. Dragging the top card sideways moves and tilts it, while the cards behind grow into place.

Releasing past `threshold`, or flicking faster than 800 points per second, flies the card off in that direction and shows the next one; otherwise it springs back. Use it for one-at-a-time decisions such as keep or skip.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/card-stack
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add card-stack
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add card-stack --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add card-stack --platform kotlin
```

The CLI also adds what it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-gesture-handler react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/haptics.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/interactions/card-stack.tsx`:

```tsx title="uniwind/components/interactions/card-stack.tsx"
import { cn } from "cn"
import {
  ArrowCounterClockwiseIcon,
  HeartIcon,
  XIcon,
} from "phosphor-react-native"
import * as React from "react"
import {
  type AccessibilityActionEvent,
  View,
  type ViewProps,
} from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import {
  Easing,
  ReduceMotion,
  runOnJS,
  type SharedValue,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  withTiming,
} from "react-native-reanimated"
import { useHaptics } from "@/components/primitives/haptics"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

type CardStackDirection = "left" | "right"

type CardStackContextProps = {
  index: number
  count: number
  remaining: number
  canUndo: boolean
  disabled: boolean
  swipe: (direction: CardStackDirection) => void
  undo: () => void
}

type CardStackInternalContextProps = {
  swipeWithFeedback: (direction: CardStackDirection) => void
}

const CardStackContext = React.createContext<CardStackContextProps | null>(null)

const CardStackInternalContext =
  React.createContext<CardStackInternalContextProps | null>(null)

function useCardStack() {
  const context = React.useContext(CardStackContext)
  if (!context) {
    throw new Error("useCardStack must be used within a <CardStack />")
  }
  return context
}

const EASING = Easing.bezier(0.32, 0.72, 0, 1)
const ROTATION = 12
const VELOCITY = 800
const SCALE_STEP = 0.05
const OFFSET_STEP = 12
const FLY_DURATION = 260
const SPRING = {
  damping: 20,
  stiffness: 220,
  reduceMotion: ReduceMotion.System,
}

function wrapText(children: React.ReactNode, className: string) {
  if (typeof children === "string" || typeof children === "number") {
    return <Text className={className}>{children}</Text>
  }
  return children
}

function StackCard({
  depth,
  visibleCount,
  translateX,
  threshold,
  children,
}: {
  depth: number
  visibleCount: number
  translateX: SharedValue<number>
  threshold: number
  children: React.ReactNode
}) {
  const animatedStyle = useAnimatedStyle(() => {
    const progress = Math.min(Math.abs(translateX.value) / threshold, 1)
    const level = depth - progress
    return {
      opacity: depth >= visibleCount ? progress : 1,
      transform: [
        { translateY: level * OFFSET_STEP },
        { scale: 1 - level * SCALE_STEP },
      ],
    }
  })

  return (
    <AnimatedView
      className="absolute inset-0"
      style={[{ zIndex: visibleCount + 1 - depth }, animatedStyle]}
      accessibilityElementsHidden
      importantForAccessibility="no-hide-descendants"
      pointerEvents="none"
    >
      {children}
    </AnimatedView>
  )
}

function TopCard({
  translateX,
  translateY,
  width,
  zIndex,
  gesture,
  accessibilityLabel,
  onAccessibilityAction,
  children,
}: {
  translateX: SharedValue<number>
  translateY: SharedValue<number>
  width: SharedValue<number>
  zIndex: number
  gesture: ReturnType<typeof Gesture.Pan>
  accessibilityLabel?: string
  onAccessibilityAction: (event: AccessibilityActionEvent) => void
  children: React.ReactNode
}) {
  const animatedStyle = useAnimatedStyle(() => {
    const base = width.value > 0 ? width.value : 1
    return {
      transform: [
        { translateX: translateX.value },
        { translateY: translateY.value },
        { rotate: `${(translateX.value / base) * ROTATION}deg` },
      ],
    }
  })

  return (
    <GestureDetector gesture={gesture}>
      <AnimatedView
        className="absolute inset-0"
        style={[{ zIndex }, animatedStyle]}
        accessible
        accessibilityLabel={accessibilityLabel}
        accessibilityActions={[
          { name: "swipeLeft", label: "Swipe left" },
          { name: "swipeRight", label: "Swipe right" },
        ]}
        onAccessibilityAction={onAccessibilityAction}
      >
        {children}
      </AnimatedView>
    </GestureDetector>
  )
}

function CardStack<T>({
  data,
  renderItem,
  keyExtractor,
  onSwipe,
  onUndo,
  threshold = 120,
  visibleCount = 3,
  disabled = false,
  itemAccessibilityLabel,
  className,
  deckClassName,
  children,
  ...props
}: ViewProps & {
  data: readonly T[]
  renderItem: (item: T, index: number) => React.ReactNode
  keyExtractor?: (item: T, index: number) => string
  onSwipe?: (direction: CardStackDirection, item: T, index: number) => void
  onUndo?: (item: T, index: number) => void
  threshold?: number
  visibleCount?: number
  disabled?: boolean
  itemAccessibilityLabel?: (item: T, index: number) => string
  deckClassName?: string
}) {
  const [index, setIndex] = React.useState(0)
  const [history, setHistory] = React.useState<CardStackDirection[]>([])
  const translateX = useSharedValue(0)
  const translateY = useSharedValue(0)
  const width = useSharedValue(0)
  const flyingRef = React.useRef(false)
  const pendingRef = React.useRef<{ index: number; from: number } | null>(null)
  const { impact } = useHaptics()

  const indexRef = React.useRef(index)
  indexRef.current = index
  const dataRef = React.useRef(data)
  dataRef.current = data
  const onSwipeRef = React.useRef(onSwipe)
  onSwipeRef.current = onSwipe

  const commit = React.useCallback(
    (direction: CardStackDirection, feedback: boolean) => {
      flyingRef.current = false
      const current = indexRef.current
      const item = dataRef.current[current]
      if (item === undefined) return
      if (feedback) impact("medium")
      pendingRef.current = { index: current + 1, from: 0 }
      setHistory((entries) => [...entries, direction])
      setIndex(current + 1)
      onSwipeRef.current?.(direction, item, current)
    },
    [impact]
  )

  React.useLayoutEffect(() => {
    const pending = pendingRef.current
    if (!pending || pending.index !== index) return
    const from = pending.from
    pendingRef.current = null
    translateY.value = 0
    if (from === 0) {
      translateX.value = 0
      return
    }
    translateX.value = from
    translateX.value = withSpring(0, SPRING)
  }, [index, translateX, translateY])

  const land = React.useCallback(
    (direction: CardStackDirection, feedback: boolean, finished: boolean) => {
      if (finished) {
        commit(direction, feedback)
        return
      }
      flyingRef.current = false
    },
    [commit]
  )

  const fly = React.useCallback(
    (direction: CardStackDirection, feedback: boolean) => {
      if (flyingRef.current || disabled) return
      if (indexRef.current >= dataRef.current.length) return
      flyingRef.current = true
      const sign = direction === "left" ? -1 : 1
      const distance = Math.max(width.value, 320) * 1.5
      translateX.value = withTiming(
        sign * distance,
        {
          duration: FLY_DURATION,
          easing: EASING,
          reduceMotion: ReduceMotion.System,
        },
        (finished) => {
          runOnJS(land)(direction, feedback, finished === true)
        }
      )
    },
    [disabled, translateX, width, land]
  )

  const swipe = React.useCallback(
    (direction: CardStackDirection) => fly(direction, false),
    [fly]
  )

  const swipeWithFeedback = React.useCallback(
    (direction: CardStackDirection) => fly(direction, true),
    [fly]
  )

  const undo = React.useCallback(() => {
    if (flyingRef.current) return
    const direction = history.at(-1)
    if (!direction || index === 0) return
    const previous = index - 1
    const item = data[previous]
    const sign = direction === "left" ? -1 : 1
    pendingRef.current = {
      index: previous,
      from: sign * Math.max(width.value, 320) * 1.5,
    }
    setHistory((entries) => entries.slice(0, -1))
    setIndex(previous)
    if (item !== undefined) onUndo?.(item, previous)
  }, [history, index, data, width, onUndo])

  const gesture = React.useMemo(
    () =>
      Gesture.Pan()
        .enabled(!disabled)
        .activeOffsetX([-10, 10])
        .onUpdate((event) => {
          translateX.value = event.translationX
          translateY.value = event.translationY * 0.4
        })
        .onEnd((event) => {
          const passed =
            Math.abs(event.translationX) > threshold ||
            Math.abs(event.velocityX) > VELOCITY
          const sign =
            Math.abs(event.velocityX) > VELOCITY
              ? Math.sign(event.velocityX)
              : Math.sign(event.translationX)
          if (passed && sign !== 0) {
            runOnJS(swipeWithFeedback)(sign < 0 ? "left" : "right")
            return
          }
          translateX.value = withSpring(0, SPRING)
          translateY.value = withSpring(0, SPRING)
        }),
    [disabled, threshold, translateX, translateY, swipeWithFeedback]
  )

  const remaining = Math.max(data.length - index, 0)
  const contextValue = React.useMemo<CardStackContextProps>(
    () => ({
      index,
      count: data.length,
      remaining,
      canUndo: history.length > 0 && index > 0,
      disabled,
      swipe,
      undo,
    }),
    [index, data.length, remaining, history.length, disabled, swipe, undo]
  )
  const internalValue = React.useMemo(
    () => ({ swipeWithFeedback }),
    [swipeWithFeedback]
  )

  const nodes = React.Children.toArray(children)
  const empty = nodes.filter(
    (node) => React.isValidElement(node) && node.type === CardStackEmpty
  )
  const rest = nodes.filter((node) => !empty.includes(node))
  const visible = data
    .slice(index, index + visibleCount + 1)
    .map((item, offset) => ({ item, position: index + offset, depth: offset }))
    .reverse()

  return (
    <CardStackContext.Provider value={contextValue}>
      <CardStackInternalContext.Provider value={internalValue}>
        <View className={cn("w-full gap-6", className)} {...props}>
          <View
            className={cn("h-96 w-full", deckClassName)}
            onLayout={(event) => {
              width.value = event.nativeEvent.layout.width
            }}
          >
            {remaining === 0 ? empty : null}
            {visible.map(({ item, position, depth }) => {
              const key = keyExtractor
                ? keyExtractor(item, position)
                : String(position)
              if (depth === 0) {
                return (
                  <TopCard
                    key={key}
                    translateX={translateX}
                    translateY={translateY}
                    width={width}
                    zIndex={visibleCount + 1}
                    gesture={gesture}
                    accessibilityLabel={itemAccessibilityLabel?.(
                      item,
                      position
                    )}
                    onAccessibilityAction={(event) => {
                      if (event.nativeEvent.actionName === "swipeLeft") {
                        swipeWithFeedback("left")
                      } else if (
                        event.nativeEvent.actionName === "swipeRight"
                      ) {
                        swipeWithFeedback("right")
                      }
                    }}
                  >
                    {renderItem(item, position)}
                  </TopCard>
                )
              }
              return (
                <StackCard
                  key={key}
                  depth={depth}
                  visibleCount={visibleCount}
                  translateX={translateX}
                  threshold={threshold}
                >
                  {renderItem(item, position)}
                </StackCard>
              )
            })}
          </View>
          {rest}
        </View>
      </CardStackInternalContext.Provider>
    </CardStackContext.Provider>
  )
}

function CardStackEmpty({
  className,
  children,
  ...props
}: ViewProps & React.RefAttributes<React.ComponentRef<typeof View>>) {
  return (
    <View
      className={cn(
        "absolute inset-0 items-center justify-center gap-2 rounded-2xl border border-border border-dashed p-6",
        className
      )}
      {...props}
    >
      {wrapText(children, "text-center text-sm text-muted-foreground")}
    </View>
  )
}

function CardStackActions({
  className,
  ...props
}: ViewProps & React.RefAttributes<React.ComponentRef<typeof View>>) {
  return (
    <View
      className={cn("flex-row items-center justify-center gap-4", className)}
      {...props}
    />
  )
}

function CardStackSwipeButton({
  direction,
  children,
  className,
  accessibilityLabel,
  disabled,
  ...props
}: Omit<React.ComponentProps<typeof Button>, "onPress"> & {
  direction: CardStackDirection
}) {
  const stack = useCardStack()
  const internal = React.useContext(CardStackInternalContext)
  return (
    <Button
      variant="outline"
      size="icon-lg"
      className={cn("size-14 rounded-full", className)}
      accessibilityLabel={
        accessibilityLabel ??
        (direction === "left" ? "Swipe left" : "Swipe right")
      }
      disabled={disabled || stack.disabled || stack.remaining === 0}
      onPress={() => internal?.swipeWithFeedback(direction)}
      {...props}
    >
      {children ?? (
        <Icon
          as={direction === "left" ? XIcon : HeartIcon}
          className={cn(
            "size-6",
            direction === "left" ? "text-destructive" : "text-primary"
          )}
        />
      )}
    </Button>
  )
}

function CardStackUndo({
  children,
  accessibilityLabel = "Undo",
  disabled,
  ...props
}: Omit<React.ComponentProps<typeof Button>, "onPress">) {
  const stack = useCardStack()
  return (
    <Button
      variant="ghost"
      size="icon-lg"
      accessibilityLabel={accessibilityLabel}
      disabled={disabled || !stack.canUndo}
      onPress={stack.undo}
      {...props}
    >
      {children ?? <Icon as={ArrowCounterClockwiseIcon} />}
    </Button>
  )
}

export type { CardStackDirection }
export {
  CardStack,
  CardStackActions,
  CardStackEmpty,
  CardStackSwipeButton,
  CardStackUndo,
  useCardStack,
}
```

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-gesture-handler react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/haptics.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/interactions/card-stack.tsx`:

```tsx title="nativewind/components/interactions/card-stack.tsx"
import { cn } from "cn"
import {
  ArrowCounterClockwiseIcon,
  HeartIcon,
  XIcon,
} from "phosphor-react-native"
import * as React from "react"
import {
  type AccessibilityActionEvent,
  View,
  type ViewProps,
} from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import {
  Easing,
  ReduceMotion,
  runOnJS,
  type SharedValue,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  withTiming,
} from "react-native-reanimated"
import { useHaptics } from "@/components/primitives/haptics"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

type CardStackDirection = "left" | "right"

type CardStackContextProps = {
  index: number
  count: number
  remaining: number
  canUndo: boolean
  disabled: boolean
  swipe: (direction: CardStackDirection) => void
  undo: () => void
}

type CardStackInternalContextProps = {
  swipeWithFeedback: (direction: CardStackDirection) => void
}

const CardStackContext = React.createContext<CardStackContextProps | null>(null)

const CardStackInternalContext =
  React.createContext<CardStackInternalContextProps | null>(null)

function useCardStack() {
  const context = React.useContext(CardStackContext)
  if (!context) {
    throw new Error("useCardStack must be used within a <CardStack />")
  }
  return context
}

const EASING = Easing.bezier(0.32, 0.72, 0, 1)
const ROTATION = 12
const VELOCITY = 800
const SCALE_STEP = 0.05
const OFFSET_STEP = 12
const FLY_DURATION = 260
const SPRING = {
  damping: 20,
  stiffness: 220,
  reduceMotion: ReduceMotion.System,
}

function wrapText(children: React.ReactNode, className: string) {
  if (typeof children === "string" || typeof children === "number") {
    return <Text className={className}>{children}</Text>
  }
  return children
}

function StackCard({
  depth,
  visibleCount,
  translateX,
  threshold,
  children,
}: {
  depth: number
  visibleCount: number
  translateX: SharedValue<number>
  threshold: number
  children: React.ReactNode
}) {
  const animatedStyle = useAnimatedStyle(() => {
    const progress = Math.min(Math.abs(translateX.value) / threshold, 1)
    const level = depth - progress
    return {
      opacity: depth >= visibleCount ? progress : 1,
      transform: [
        { translateY: level * OFFSET_STEP },
        { scale: 1 - level * SCALE_STEP },
      ],
    }
  })

  return (
    <AnimatedView
      className="absolute inset-0"
      style={[{ zIndex: visibleCount + 1 - depth }, animatedStyle]}
      accessibilityElementsHidden
      importantForAccessibility="no-hide-descendants"
      pointerEvents="none"
    >
      {children}
    </AnimatedView>
  )
}

function TopCard({
  translateX,
  translateY,
  width,
  zIndex,
  gesture,
  accessibilityLabel,
  onAccessibilityAction,
  children,
}: {
  translateX: SharedValue<number>
  translateY: SharedValue<number>
  width: SharedValue<number>
  zIndex: number
  gesture: ReturnType<typeof Gesture.Pan>
  accessibilityLabel?: string
  onAccessibilityAction: (event: AccessibilityActionEvent) => void
  children: React.ReactNode
}) {
  const animatedStyle = useAnimatedStyle(() => {
    const base = width.value > 0 ? width.value : 1
    return {
      transform: [
        { translateX: translateX.value },
        { translateY: translateY.value },
        { rotate: `${(translateX.value / base) * ROTATION}deg` },
      ],
    }
  })

  return (
    <GestureDetector gesture={gesture}>
      <AnimatedView
        className="absolute inset-0"
        style={[{ zIndex }, animatedStyle]}
        accessible
        accessibilityLabel={accessibilityLabel}
        accessibilityActions={[
          { name: "swipeLeft", label: "Swipe left" },
          { name: "swipeRight", label: "Swipe right" },
        ]}
        onAccessibilityAction={onAccessibilityAction}
      >
        {children}
      </AnimatedView>
    </GestureDetector>
  )
}

function CardStack<T>({
  data,
  renderItem,
  keyExtractor,
  onSwipe,
  onUndo,
  threshold = 120,
  visibleCount = 3,
  disabled = false,
  itemAccessibilityLabel,
  className,
  deckClassName,
  children,
  ...props
}: ViewProps & {
  data: readonly T[]
  renderItem: (item: T, index: number) => React.ReactNode
  keyExtractor?: (item: T, index: number) => string
  onSwipe?: (direction: CardStackDirection, item: T, index: number) => void
  onUndo?: (item: T, index: number) => void
  threshold?: number
  visibleCount?: number
  disabled?: boolean
  itemAccessibilityLabel?: (item: T, index: number) => string
  deckClassName?: string
}) {
  const [index, setIndex] = React.useState(0)
  const [history, setHistory] = React.useState<CardStackDirection[]>([])
  const translateX = useSharedValue(0)
  const translateY = useSharedValue(0)
  const width = useSharedValue(0)
  const flyingRef = React.useRef(false)
  const pendingRef = React.useRef<{ index: number; from: number } | null>(null)
  const { impact } = useHaptics()

  const indexRef = React.useRef(index)
  indexRef.current = index
  const dataRef = React.useRef(data)
  dataRef.current = data
  const onSwipeRef = React.useRef(onSwipe)
  onSwipeRef.current = onSwipe

  const commit = React.useCallback(
    (direction: CardStackDirection, feedback: boolean) => {
      flyingRef.current = false
      const current = indexRef.current
      const item = dataRef.current[current]
      if (item === undefined) return
      if (feedback) impact("medium")
      pendingRef.current = { index: current + 1, from: 0 }
      setHistory((entries) => [...entries, direction])
      setIndex(current + 1)
      onSwipeRef.current?.(direction, item, current)
    },
    [impact]
  )

  React.useLayoutEffect(() => {
    const pending = pendingRef.current
    if (!pending || pending.index !== index) return
    const from = pending.from
    pendingRef.current = null
    translateY.value = 0
    if (from === 0) {
      translateX.value = 0
      return
    }
    translateX.value = from
    translateX.value = withSpring(0, SPRING)
  }, [index, translateX, translateY])

  const land = React.useCallback(
    (direction: CardStackDirection, feedback: boolean, finished: boolean) => {
      if (finished) {
        commit(direction, feedback)
        return
      }
      flyingRef.current = false
    },
    [commit]
  )

  const fly = React.useCallback(
    (direction: CardStackDirection, feedback: boolean) => {
      if (flyingRef.current || disabled) return
      if (indexRef.current >= dataRef.current.length) return
      flyingRef.current = true
      const sign = direction === "left" ? -1 : 1
      const distance = Math.max(width.value, 320) * 1.5
      translateX.value = withTiming(
        sign * distance,
        {
          duration: FLY_DURATION,
          easing: EASING,
          reduceMotion: ReduceMotion.System,
        },
        (finished) => {
          runOnJS(land)(direction, feedback, finished === true)
        }
      )
    },
    [disabled, translateX, width, land]
  )

  const swipe = React.useCallback(
    (direction: CardStackDirection) => fly(direction, false),
    [fly]
  )

  const swipeWithFeedback = React.useCallback(
    (direction: CardStackDirection) => fly(direction, true),
    [fly]
  )

  const undo = React.useCallback(() => {
    if (flyingRef.current) return
    const direction = history.at(-1)
    if (!direction || index === 0) return
    const previous = index - 1
    const item = data[previous]
    const sign = direction === "left" ? -1 : 1
    pendingRef.current = {
      index: previous,
      from: sign * Math.max(width.value, 320) * 1.5,
    }
    setHistory((entries) => entries.slice(0, -1))
    setIndex(previous)
    if (item !== undefined) onUndo?.(item, previous)
  }, [history, index, data, width, onUndo])

  const gesture = React.useMemo(
    () =>
      Gesture.Pan()
        .enabled(!disabled)
        .activeOffsetX([-10, 10])
        .onUpdate((event) => {
          translateX.value = event.translationX
          translateY.value = event.translationY * 0.4
        })
        .onEnd((event) => {
          const passed =
            Math.abs(event.translationX) > threshold ||
            Math.abs(event.velocityX) > VELOCITY
          const sign =
            Math.abs(event.velocityX) > VELOCITY
              ? Math.sign(event.velocityX)
              : Math.sign(event.translationX)
          if (passed && sign !== 0) {
            runOnJS(swipeWithFeedback)(sign < 0 ? "left" : "right")
            return
          }
          translateX.value = withSpring(0, SPRING)
          translateY.value = withSpring(0, SPRING)
        }),
    [disabled, threshold, translateX, translateY, swipeWithFeedback]
  )

  const remaining = Math.max(data.length - index, 0)
  const contextValue = React.useMemo<CardStackContextProps>(
    () => ({
      index,
      count: data.length,
      remaining,
      canUndo: history.length > 0 && index > 0,
      disabled,
      swipe,
      undo,
    }),
    [index, data.length, remaining, history.length, disabled, swipe, undo]
  )
  const internalValue = React.useMemo(
    () => ({ swipeWithFeedback }),
    [swipeWithFeedback]
  )

  const nodes = React.Children.toArray(children)
  const empty = nodes.filter(
    (node) => React.isValidElement(node) && node.type === CardStackEmpty
  )
  const rest = nodes.filter((node) => !empty.includes(node))
  const visible = data
    .slice(index, index + visibleCount + 1)
    .map((item, offset) => ({ item, position: index + offset, depth: offset }))
    .reverse()

  return (
    <CardStackContext.Provider value={contextValue}>
      <CardStackInternalContext.Provider value={internalValue}>
        <View className={cn("w-full gap-6", className)} {...props}>
          <View
            className={cn("h-96 w-full", deckClassName)}
            onLayout={(event) => {
              width.value = event.nativeEvent.layout.width
            }}
          >
            {remaining === 0 ? empty : null}
            {visible.map(({ item, position, depth }) => {
              const key = keyExtractor
                ? keyExtractor(item, position)
                : String(position)
              if (depth === 0) {
                return (
                  <TopCard
                    key={key}
                    translateX={translateX}
                    translateY={translateY}
                    width={width}
                    zIndex={visibleCount + 1}
                    gesture={gesture}
                    accessibilityLabel={itemAccessibilityLabel?.(
                      item,
                      position
                    )}
                    onAccessibilityAction={(event) => {
                      if (event.nativeEvent.actionName === "swipeLeft") {
                        swipeWithFeedback("left")
                      } else if (
                        event.nativeEvent.actionName === "swipeRight"
                      ) {
                        swipeWithFeedback("right")
                      }
                    }}
                  >
                    {renderItem(item, position)}
                  </TopCard>
                )
              }
              return (
                <StackCard
                  key={key}
                  depth={depth}
                  visibleCount={visibleCount}
                  translateX={translateX}
                  threshold={threshold}
                >
                  {renderItem(item, position)}
                </StackCard>
              )
            })}
          </View>
          {rest}
        </View>
      </CardStackInternalContext.Provider>
    </CardStackContext.Provider>
  )
}

function CardStackEmpty({
  className,
  children,
  ...props
}: ViewProps & React.RefAttributes<React.ComponentRef<typeof View>>) {
  return (
    <View
      className={cn(
        "absolute inset-0 items-center justify-center gap-2 rounded-2xl border border-border border-dashed p-6",
        className
      )}
      {...props}
    >
      {wrapText(children, "text-center text-sm text-muted-foreground")}
    </View>
  )
}

function CardStackActions({
  className,
  ...props
}: ViewProps & React.RefAttributes<React.ComponentRef<typeof View>>) {
  return (
    <View
      className={cn("flex-row items-center justify-center gap-4", className)}
      {...props}
    />
  )
}

function CardStackSwipeButton({
  direction,
  children,
  className,
  accessibilityLabel,
  disabled,
  ...props
}: Omit<React.ComponentProps<typeof Button>, "onPress"> & {
  direction: CardStackDirection
}) {
  const stack = useCardStack()
  const internal = React.useContext(CardStackInternalContext)
  return (
    <Button
      variant="outline"
      size="icon-lg"
      className={cn("size-14 rounded-full", className)}
      accessibilityLabel={
        accessibilityLabel ??
        (direction === "left" ? "Swipe left" : "Swipe right")
      }
      disabled={disabled || stack.disabled || stack.remaining === 0}
      onPress={() => internal?.swipeWithFeedback(direction)}
      {...props}
    >
      {children ?? (
        <Icon
          as={direction === "left" ? XIcon : HeartIcon}
          className={cn(
            "size-6",
            direction === "left" ? "text-destructive" : "text-primary"
          )}
        />
      )}
    </Button>
  )
}

function CardStackUndo({
  children,
  accessibilityLabel = "Undo",
  disabled,
  ...props
}: Omit<React.ComponentProps<typeof Button>, "onPress">) {
  const stack = useCardStack()
  return (
    <Button
      variant="ghost"
      size="icon-lg"
      accessibilityLabel={accessibilityLabel}
      disabled={disabled || !stack.canUndo}
      onPress={stack.undo}
      {...props}
    >
      {children ?? <Icon as={ArrowCounterClockwiseIcon} />}
    </Button>
  )
}

export type { CardStackDirection }
export {
  CardStack,
  CardStackActions,
  CardStackEmpty,
  CardStackSwipeButton,
  CardStackUndo,
  useCardStack,
}
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-gesture-handler react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/haptics.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/interactions/card-stack.tsx`:

```tsx title="stylesheet/components/interactions/card-stack.tsx"
import {
  ArrowCounterClockwiseIcon,
  HeartIcon,
  XIcon,
} from "phosphor-react-native"
import * as React from "react"
import {
  type AccessibilityActionEvent,
  type StyleProp,
  StyleSheet,
  type TextStyle,
  View,
  type ViewProps,
  type ViewStyle,
} from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import {
  Easing,
  ReduceMotion,
  runOnJS,
  type SharedValue,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  withTiming,
} from "react-native-reanimated"
import { useHaptics } from "@/components/primitives/haptics"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"
import { fontSize, radius, useTheme } from "@/components/ui/theme"

type CardStackDirection = "left" | "right"

type CardStackContextProps = {
  index: number
  count: number
  remaining: number
  canUndo: boolean
  disabled: boolean
  swipe: (direction: CardStackDirection) => void
  undo: () => void
}

type CardStackInternalContextProps = {
  swipeWithFeedback: (direction: CardStackDirection) => void
}

const CardStackContext = React.createContext<CardStackContextProps | null>(null)

const CardStackInternalContext =
  React.createContext<CardStackInternalContextProps | null>(null)

function useCardStack() {
  const context = React.useContext(CardStackContext)
  if (!context) {
    throw new Error("useCardStack must be used within a <CardStack />")
  }
  return context
}

const EASING = Easing.bezier(0.32, 0.72, 0, 1)
const ROTATION = 12
const VELOCITY = 800
const SCALE_STEP = 0.05
const OFFSET_STEP = 12
const FLY_DURATION = 260
const SPRING = {
  damping: 20,
  stiffness: 220,
  reduceMotion: ReduceMotion.System,
}

const styles = StyleSheet.create({
  root: { width: "100%", gap: 24 },
  deck: { width: "100%", height: 384 },
  empty: {
    position: "absolute",
    top: 0,
    right: 0,
    bottom: 0,
    left: 0,
    alignItems: "center",
    justifyContent: "center",
    gap: 8,
    borderRadius: radius["2xl"],
    borderWidth: 1,
    borderStyle: "dashed",
    padding: 24,
  },
  emptyText: { ...fontSize.sm, textAlign: "center" },
  actions: {
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "center",
    gap: 16,
  },
  swipeButton: { width: 56, height: 56, borderRadius: radius.full },
})

function wrapText(children: React.ReactNode, style: TextStyle) {
  if (typeof children === "string" || typeof children === "number") {
    return <Text style={style}>{children}</Text>
  }
  return children
}

function StackCard({
  depth,
  visibleCount,
  translateX,
  threshold,
  children,
}: {
  depth: number
  visibleCount: number
  translateX: SharedValue<number>
  threshold: number
  children: React.ReactNode
}) {
  const animatedStyle = useAnimatedStyle(() => {
    const progress = Math.min(Math.abs(translateX.value) / threshold, 1)
    const level = depth - progress
    return {
      opacity: depth >= visibleCount ? progress : 1,
      transform: [
        { translateY: level * OFFSET_STEP },
        { scale: 1 - level * SCALE_STEP },
      ],
    }
  })

  return (
    <AnimatedView
      style={[
        StyleSheet.absoluteFill,
        { zIndex: visibleCount + 1 - depth },
        animatedStyle,
      ]}
      accessibilityElementsHidden
      importantForAccessibility="no-hide-descendants"
      pointerEvents="none"
    >
      {children}
    </AnimatedView>
  )
}

function TopCard({
  translateX,
  translateY,
  width,
  zIndex,
  gesture,
  accessibilityLabel,
  onAccessibilityAction,
  children,
}: {
  translateX: SharedValue<number>
  translateY: SharedValue<number>
  width: SharedValue<number>
  zIndex: number
  gesture: ReturnType<typeof Gesture.Pan>
  accessibilityLabel?: string
  onAccessibilityAction: (event: AccessibilityActionEvent) => void
  children: React.ReactNode
}) {
  const animatedStyle = useAnimatedStyle(() => {
    const base = width.value > 0 ? width.value : 1
    return {
      transform: [
        { translateX: translateX.value },
        { translateY: translateY.value },
        { rotate: `${(translateX.value / base) * ROTATION}deg` },
      ],
    }
  })

  return (
    <GestureDetector gesture={gesture}>
      <AnimatedView
        style={[StyleSheet.absoluteFill, { zIndex }, animatedStyle]}
        accessible
        accessibilityLabel={accessibilityLabel}
        accessibilityActions={[
          { name: "swipeLeft", label: "Swipe left" },
          { name: "swipeRight", label: "Swipe right" },
        ]}
        onAccessibilityAction={onAccessibilityAction}
      >
        {children}
      </AnimatedView>
    </GestureDetector>
  )
}

function CardStack<T>({
  data,
  renderItem,
  keyExtractor,
  onSwipe,
  onUndo,
  threshold = 120,
  visibleCount = 3,
  disabled = false,
  itemAccessibilityLabel,
  style,
  deckStyle,
  children,
  ...props
}: ViewProps & {
  data: readonly T[]
  renderItem: (item: T, index: number) => React.ReactNode
  keyExtractor?: (item: T, index: number) => string
  onSwipe?: (direction: CardStackDirection, item: T, index: number) => void
  onUndo?: (item: T, index: number) => void
  threshold?: number
  visibleCount?: number
  disabled?: boolean
  itemAccessibilityLabel?: (item: T, index: number) => string
  deckStyle?: StyleProp<ViewStyle>
}) {
  const [index, setIndex] = React.useState(0)
  const [history, setHistory] = React.useState<CardStackDirection[]>([])
  const translateX = useSharedValue(0)
  const translateY = useSharedValue(0)
  const width = useSharedValue(0)
  const flyingRef = React.useRef(false)
  const pendingRef = React.useRef<{ index: number; from: number } | null>(null)
  const { impact } = useHaptics()

  const indexRef = React.useRef(index)
  indexRef.current = index
  const dataRef = React.useRef(data)
  dataRef.current = data
  const onSwipeRef = React.useRef(onSwipe)
  onSwipeRef.current = onSwipe

  const commit = React.useCallback(
    (direction: CardStackDirection, feedback: boolean) => {
      flyingRef.current = false
      const current = indexRef.current
      const item = dataRef.current[current]
      if (item === undefined) return
      if (feedback) impact("medium")
      pendingRef.current = { index: current + 1, from: 0 }
      setHistory((entries) => [...entries, direction])
      setIndex(current + 1)
      onSwipeRef.current?.(direction, item, current)
    },
    [impact]
  )

  React.useLayoutEffect(() => {
    const pending = pendingRef.current
    if (!pending || pending.index !== index) return
    const from = pending.from
    pendingRef.current = null
    translateY.value = 0
    if (from === 0) {
      translateX.value = 0
      return
    }
    translateX.value = from
    translateX.value = withSpring(0, SPRING)
  }, [index, translateX, translateY])

  const land = React.useCallback(
    (direction: CardStackDirection, feedback: boolean, finished: boolean) => {
      if (finished) {
        commit(direction, feedback)
        return
      }
      flyingRef.current = false
    },
    [commit]
  )

  const fly = React.useCallback(
    (direction: CardStackDirection, feedback: boolean) => {
      if (flyingRef.current || disabled) return
      if (indexRef.current >= dataRef.current.length) return
      flyingRef.current = true
      const sign = direction === "left" ? -1 : 1
      const distance = Math.max(width.value, 320) * 1.5
      translateX.value = withTiming(
        sign * distance,
        {
          duration: FLY_DURATION,
          easing: EASING,
          reduceMotion: ReduceMotion.System,
        },
        (finished) => {
          runOnJS(land)(direction, feedback, finished === true)
        }
      )
    },
    [disabled, translateX, width, land]
  )

  const swipe = React.useCallback(
    (direction: CardStackDirection) => fly(direction, false),
    [fly]
  )

  const swipeWithFeedback = React.useCallback(
    (direction: CardStackDirection) => fly(direction, true),
    [fly]
  )

  const undo = React.useCallback(() => {
    if (flyingRef.current) return
    const direction = history.at(-1)
    if (!direction || index === 0) return
    const previous = index - 1
    const item = data[previous]
    const sign = direction === "left" ? -1 : 1
    pendingRef.current = {
      index: previous,
      from: sign * Math.max(width.value, 320) * 1.5,
    }
    setHistory((entries) => entries.slice(0, -1))
    setIndex(previous)
    if (item !== undefined) onUndo?.(item, previous)
  }, [history, index, data, width, onUndo])

  const gesture = React.useMemo(
    () =>
      Gesture.Pan()
        .enabled(!disabled)
        .activeOffsetX([-10, 10])
        .onUpdate((event) => {
          translateX.value = event.translationX
          translateY.value = event.translationY * 0.4
        })
        .onEnd((event) => {
          const passed =
            Math.abs(event.translationX) > threshold ||
            Math.abs(event.velocityX) > VELOCITY
          const sign =
            Math.abs(event.velocityX) > VELOCITY
              ? Math.sign(event.velocityX)
              : Math.sign(event.translationX)
          if (passed && sign !== 0) {
            runOnJS(swipeWithFeedback)(sign < 0 ? "left" : "right")
            return
          }
          translateX.value = withSpring(0, SPRING)
          translateY.value = withSpring(0, SPRING)
        }),
    [disabled, threshold, translateX, translateY, swipeWithFeedback]
  )

  const remaining = Math.max(data.length - index, 0)
  const contextValue = React.useMemo<CardStackContextProps>(
    () => ({
      index,
      count: data.length,
      remaining,
      canUndo: history.length > 0 && index > 0,
      disabled,
      swipe,
      undo,
    }),
    [index, data.length, remaining, history.length, disabled, swipe, undo]
  )
  const internalValue = React.useMemo(
    () => ({ swipeWithFeedback }),
    [swipeWithFeedback]
  )

  const nodes = React.Children.toArray(children)
  const empty = nodes.filter(
    (node) => React.isValidElement(node) && node.type === CardStackEmpty
  )
  const rest = nodes.filter((node) => !empty.includes(node))
  const visible = data
    .slice(index, index + visibleCount + 1)
    .map((item, offset) => ({ item, position: index + offset, depth: offset }))
    .reverse()

  return (
    <CardStackContext.Provider value={contextValue}>
      <CardStackInternalContext.Provider value={internalValue}>
        <View style={[styles.root, style]} {...props}>
          <View
            style={[styles.deck, deckStyle]}
            onLayout={(event) => {
              width.value = event.nativeEvent.layout.width
            }}
          >
            {remaining === 0 ? empty : null}
            {visible.map(({ item, position, depth }) => {
              const key = keyExtractor
                ? keyExtractor(item, position)
                : String(position)
              if (depth === 0) {
                return (
                  <TopCard
                    key={key}
                    translateX={translateX}
                    translateY={translateY}
                    width={width}
                    zIndex={visibleCount + 1}
                    gesture={gesture}
                    accessibilityLabel={itemAccessibilityLabel?.(
                      item,
                      position
                    )}
                    onAccessibilityAction={(event) => {
                      if (event.nativeEvent.actionName === "swipeLeft") {
                        swipeWithFeedback("left")
                      } else if (
                        event.nativeEvent.actionName === "swipeRight"
                      ) {
                        swipeWithFeedback("right")
                      }
                    }}
                  >
                    {renderItem(item, position)}
                  </TopCard>
                )
              }
              return (
                <StackCard
                  key={key}
                  depth={depth}
                  visibleCount={visibleCount}
                  translateX={translateX}
                  threshold={threshold}
                >
                  {renderItem(item, position)}
                </StackCard>
              )
            })}
          </View>
          {rest}
        </View>
      </CardStackInternalContext.Provider>
    </CardStackContext.Provider>
  )
}

function CardStackEmpty({
  style,
  children,
  ...props
}: ViewProps & React.RefAttributes<React.ComponentRef<typeof View>>) {
  const { colors } = useTheme()
  return (
    <View
      style={[styles.empty, { borderColor: colors.border }, style]}
      {...props}
    >
      {wrapText(children, {
        ...styles.emptyText,
        color: colors.mutedForeground,
      })}
    </View>
  )
}

function CardStackActions({
  style,
  ...props
}: ViewProps & React.RefAttributes<React.ComponentRef<typeof View>>) {
  return <View style={[styles.actions, style]} {...props} />
}

function CardStackSwipeButton({
  direction,
  children,
  style,
  accessibilityLabel,
  disabled,
  ...props
}: Omit<React.ComponentProps<typeof Button>, "onPress"> & {
  direction: CardStackDirection
}) {
  const { colors } = useTheme()
  const stack = useCardStack()
  const internal = React.useContext(CardStackInternalContext)
  return (
    <Button
      variant="outline"
      size="icon-lg"
      style={(state) => [
        styles.swipeButton,
        typeof style === "function" ? style(state) : style,
      ]}
      accessibilityLabel={
        accessibilityLabel ??
        (direction === "left" ? "Swipe left" : "Swipe right")
      }
      disabled={disabled || stack.disabled || stack.remaining === 0}
      onPress={() => internal?.swipeWithFeedback(direction)}
      {...props}
    >
      {children ?? (
        <Icon
          as={direction === "left" ? XIcon : HeartIcon}
          size={24}
          color={direction === "left" ? colors.destructive : colors.primary}
        />
      )}
    </Button>
  )
}

function CardStackUndo({
  children,
  accessibilityLabel = "Undo",
  disabled,
  ...props
}: Omit<React.ComponentProps<typeof Button>, "onPress">) {
  const stack = useCardStack()
  return (
    <Button
      variant="ghost"
      size="icon-lg"
      accessibilityLabel={accessibilityLabel}
      disabled={disabled || !stack.canUndo}
      onPress={stack.undo}
      {...props}
    >
      {children ?? <Icon as={ArrowCounterClockwiseIcon} />}
    </Button>
  )
}

export type { CardStackDirection }
export {
  CardStack,
  CardStackActions,
  CardStackEmpty,
  CardStackSwipeButton,
  CardStackUndo,
  useCardStack,
}
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

```swift title="swift/Interactions/CardStack.swift"
import Observation
import PhosphorSwift
import SwiftUI

public enum CardStackDirection: CaseIterable, Sendable {
  case left, right

  var sign: CGFloat { self == .left ? -1 : 1 }
}

enum CardStackMath {
  static let maxRotation: Double = 12
  static let depthScale: CGFloat = 0.05
  static let depthOffset: CGFloat = 12

  static func threshold(width: CGFloat) -> CGFloat {
    min(120, width * 0.3)
  }

  static func direction(translation: CGFloat, predicted: CGFloat, width: CGFloat) -> CardStackDirection? {
    let limit = threshold(width: width)
    if translation <= -limit || (predicted <= -width * 0.6 && translation < 0) { return .left }
    if translation >= limit || (predicted >= width * 0.6 && translation > 0) { return .right }
    return nil
  }

  static func rotation(translation: CGFloat, width: CGFloat) -> Double {
    guard width > 0 else { return 0 }
    let value = Double(translation / width) * maxRotation * 1.5
    return min(max(value, -maxRotation), maxRotation)
  }

  static func progress(translation: CGFloat, width: CGFloat) -> CGFloat {
    let limit = threshold(width: width)
    guard limit > 0 else { return 0 }
    return min(abs(translation) / limit, 1)
  }

  static func scale(depth: Int, progress: CGFloat) -> CGFloat {
    let position = max(CGFloat(depth) - progress, 0)
    return 1 - position * depthScale
  }

  static func offset(depth: Int, progress: CGFloat) -> CGFloat {
    let position = max(CGFloat(depth) - progress, 0)
    return position * depthOffset
  }

  static func exit(direction: CardStackDirection, width: CGFloat) -> CGFloat {
    direction.sign * (width * 1.5 + 80)
  }
}

@MainActor
@Observable
public final class CardStackController {
  public private(set) var index = 0
  public private(set) var history: [CardStackDirection] = []
  public internal(set) var count = 0

  var pending: CardStackDirection?
  var restoring: CardStackDirection?

  public init() {}

  public var canSwipe: Bool { index < count }

  public var canUndo: Bool { !history.isEmpty && index > 0 }

  public func swipe(_ direction: CardStackDirection) {
    guard canSwipe, pending == nil else { return }
    pending = direction
  }

  public func undo() {
    guard canUndo, let last = history.popLast() else { return }
    index -= 1
    restoring = last
  }

  public func reset() {
    index = 0
    history = []
    pending = nil
    restoring = nil
  }

  func commit(_ direction: CardStackDirection) {
    guard index < count else { return }
    history.append(direction)
    index += 1
    pending = nil
  }
}

public struct CardStack<Item: Identifiable, Card: View>: View {
  let items: [Item]
  let controller: CardStackController
  let visibleCount: Int
  let onSwipe: ((CardStackDirection, Item) -> Void)?
  let onUndo: ((Item) -> Void)?
  let card: (Item) -> Card

  @State private var drag: CGSize
  @State private var exiting: CardStackDirection?
  @State private var width: CGFloat = 0
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    _ items: [Item],
    controller: CardStackController,
    visibleCount: Int = 3,
    onSwipe: ((CardStackDirection, Item) -> Void)? = nil,
    onUndo: ((Item) -> Void)? = nil,
    @ViewBuilder card: @escaping (Item) -> Card
  ) {
    self.items = items
    self.controller = controller
    self.visibleCount = max(visibleCount, 1)
    self.onSwipe = onSwipe
    self.onUndo = onUndo
    self.card = card
    _drag = State(initialValue: .zero)
  }

  init(
    _ items: [Item],
    controller: CardStackController,
    drag: CGSize,
    @ViewBuilder card: @escaping (Item) -> Card
  ) {
    self.init(items, controller: controller, card: card)
    _drag = State(initialValue: drag)
  }

  public var body: some View {
    let start = min(controller.index, items.count)
    let end = min(start + visibleCount, items.count)
    let visible = Array(items[start..<end].enumerated())
    let progress = CardStackMath.progress(translation: drag.width, width: width)
    ZStack {
      ForEach(visible.reversed(), id: \.element.id) { depth, item in
        let top = depth == 0
        card(item)
          .frame(maxWidth: .infinity, maxHeight: .infinity)
          .scaleEffect(top ? 1 : CardStackMath.scale(depth: depth, progress: progress), anchor: .bottom)
          .offset(y: top ? 0 : CardStackMath.offset(depth: depth, progress: progress))
          .offset(x: top ? drag.width : 0, y: top ? drag.height * 0.4 : 0)
          .rotationEffect(.degrees(top ? CardStackMath.rotation(translation: drag.width, width: width) : 0), anchor: .bottom)
          .zIndex(Double(visibleCount - depth))
          .allowsHitTesting(top)
          .gesture(dragGesture(item), including: top ? .all : .subviews)
          .accessibilityHidden(!top)
          .accessibilityActions {
            if top {
              Button("Swipe left") { controller.swipe(.left) }
              Button("Swipe right") { controller.swipe(.right) }
            }
          }
          .transition(.identity)
      }
    }
    .padding(.bottom, CardStackMath.depthOffset * CGFloat(max(visibleCount - 1, 0)))
    .onGeometryChange(for: CGFloat.self) { proxy in
      proxy.size.width
    } action: { value in
      width = value
    }
    .hapticFeedback(.impact(weight: .medium), trigger: controller.index) { old, new in new > old }
    .onAppear { controller.count = items.count }
    .onChange(of: items.count) { _, value in controller.count = value }
    .onChange(of: controller.pending) { _, value in
      if let value { fling(value) }
    }
    .onChange(of: controller.restoring) { _, value in
      guard let value else { return }
      restore(value)
    }
  }

  private func dragGesture(_ item: Item) -> some Gesture {
    DragGesture(minimumDistance: 8)
      .onChanged { value in
        guard exiting == nil else { return }
        drag = value.translation
      }
      .onEnded { value in
        guard exiting == nil else { return }
        if let direction = CardStackMath.direction(
          translation: value.translation.width,
          predicted: value.predictedEndTranslation.width,
          width: width
        ) {
          fling(direction)
        } else {
          withAnimation(reduceMotion ? .linear(duration: 0.15) : .spring(duration: 0.4, bounce: 0.35)) {
            drag = .zero
          }
        }
      }
  }

  private func fling(_ direction: CardStackDirection) {
    let start = controller.index
    guard start < items.count else {
      controller.pending = nil
      return
    }
    let item = items[start]
    exiting = direction
    let target = CGSize(width: CardStackMath.exit(direction: direction, width: max(width, 320)), height: drag.height + 40)
    withAnimation(reduceMotion ? .linear(duration: 0.15) : .easeOut(duration: 0.3)) {
      drag = target
    } completion: {
      var transaction = Transaction()
      transaction.disablesAnimations = true
      withTransaction(transaction) {
        controller.commit(direction)
        drag = .zero
        exiting = nil
      }
      onSwipe?(direction, item)
    }
  }

  private func restore(_ direction: CardStackDirection) {
    let index = controller.index
    controller.restoring = nil
    guard index < items.count else { return }
    var transaction = Transaction()
    transaction.disablesAnimations = true
    withTransaction(transaction) {
      drag = CGSize(width: CardStackMath.exit(direction: direction, width: max(width, 320)), height: 40)
    }
    withAnimation(reduceMotion ? .linear(duration: 0.15) : .spring(duration: 0.45, bounce: 0.2)) {
      drag = .zero
    }
    onUndo?(items[index])
  }
}

public enum CardStackAction: CaseIterable, Sendable {
  case swipeLeft, swipeRight, undo
}

public struct CardStackButton: View {
  let action: CardStackAction
  let controller: CardStackController
  let size: VariantButtonStyle.Size

  public init(_ action: CardStackAction, controller: CardStackController, size: VariantButtonStyle.Size = .iconXl) {
    self.action = action
    self.controller = controller
    self.size = size
  }

  public var body: some View {
    let enabled = action == .undo ? controller.canUndo : controller.canSwipe
    Button {
      switch action {
      case .swipeLeft: controller.swipe(.left)
      case .swipeRight: controller.swipe(.right)
      case .undo: controller.undo()
      }
    } label: {
      icon
        .frame(width: size == .iconXl ? 22 : size.iconSize, height: size == .iconXl ? 22 : size.iconSize)
    }
    .buttonStyle(.variant(variant, size: size))
    .clipShape(Circle())
    .disabled(!enabled)
    .accessibilityLabel(Text(label))
  }

  private var variant: VariantButtonStyle.Variant {
    switch action {
    case .swipeLeft: .secondary
    case .swipeRight: .default
    case .undo: .ghost
    }
  }

  @ViewBuilder private var icon: some View {
    switch action {
    case .swipeLeft: Ph.x.bold
    case .swipeRight: Ph.check.bold
    case .undo: Ph.arrowCounterClockwise.bold
    }
  }

  private var label: LocalizedStringKey {
    switch action {
    case .swipeLeft: "Swipe left"
    case .swipeRight: "Swipe right"
    case .undo: "Undo"
    }
  }
}
```

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Card.swift`: see [Card](https://ui.suiss.app/docs/components/card.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/Item.swift`: see [Item](https://ui.suiss.app/docs/components/item.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/interactions/CardStack.kt"
package com.esuiss.ui.interactions

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key as identity
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import com.esuiss.ui.components.formReducedMotion
import com.esuiss.ui.components.rememberHaptics
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.abs

enum class CardStackDirection { Left, Right }

@Stable
class CardStackState internal constructor(initialIndex: Int, private val scope: CoroutineScope) {
    var index by mutableIntStateOf(initialIndex)
        private set
    internal val history = mutableStateListOf<CardStackDirection>()
    internal val offset = Animatable(Offset.Zero, Offset.VectorConverter)
    internal var width = 0f
    internal var count = 0
    internal var busy by mutableStateOf(false)
    internal var onSwiped: ((CardStackDirection, Int) -> Unit)? = null
    internal var onUndone: ((CardStackDirection, Int) -> Unit)? = null
    internal var reduced = false

    val canUndo: Boolean
        get() = history.isNotEmpty() && index > 0

    val isEmpty: Boolean
        get() = index >= count

    val progress: Float
        get() = if (width <= 0f) 0f else (offset.value.x / (width * 0.35f)).coerceIn(-1f, 1f)

    fun swipe(direction: CardStackDirection) {
        if (busy || isEmpty) return
        scope.launch { dismiss(direction) }
    }

    fun undo() {
        if (busy || !canUndo) return
        scope.launch {
            busy = true
            val direction = history.removeAt(history.lastIndex)
            index -= 1
            offset.snapTo(Offset(if (direction == CardStackDirection.Left) -width * 1.5f else width * 1.5f, 0f))
            onUndone?.invoke(direction, index)
            offset.animateTo(Offset.Zero, if (reduced) tween(0) else spring(0.8f, Spring.StiffnessMediumLow))
            busy = false
        }
    }

    internal suspend fun dismiss(direction: CardStackDirection) {
        busy = true
        val target = Offset(if (direction == CardStackDirection.Left) -width * 1.5f else width * 1.5f, offset.value.y)
        offset.animateTo(target, tween(if (reduced) 0 else 260))
        val swiped = index
        history.add(direction)
        index += 1
        offset.snapTo(Offset.Zero)
        busy = false
        onSwiped?.invoke(direction, swiped)
    }

    internal suspend fun settle() {
        offset.animateTo(Offset.Zero, if (reduced) tween(0) else spring(0.7f, Spring.StiffnessMedium))
    }
}

@Composable
fun rememberCardStackState(initialIndex: Int = 0): CardStackState {
    val scope = rememberCoroutineScope()
    return remember(scope) { CardStackState(initialIndex, scope) }
}

@Composable
fun <T> CardStack(
    items: List<T>,
    onSwipe: (CardStackDirection, T) -> Unit,
    modifier: Modifier = Modifier,
    state: CardStackState = rememberCardStackState(),
    key: ((T) -> Any)? = null,
    visibleCount: Int = 3,
    enabled: Boolean = true,
    onUndo: ((CardStackDirection, T) -> Unit)? = null,
    empty: @Composable () -> Unit = {},
    content: @Composable (T) -> Unit,
) {
    val haptics = rememberHaptics()
    val scope = rememberCoroutineScope()
    val density = LocalDensity.current
    val swipe by rememberUpdatedState(onSwipe)
    val undo by rememberUpdatedState(onUndo)
    val list by rememberUpdatedState(items)
    state.count = items.size
    state.reduced = formReducedMotion()
    LaunchedEffect(state) {
        state.onSwiped = { direction, index ->
            haptics.perform(HapticFeedbackType.GestureThresholdActivate)
            list.getOrNull(index)?.let { swipe(direction, it) }
        }
        state.onUndone = { direction, index ->
            list.getOrNull(index)?.let { undo?.invoke(direction, it) }
        }
    }
    BoxWithConstraints(
        modifier = modifier.fillMaxWidth().padding(bottom = 10.dp * (visibleCount - 1).coerceAtLeast(0)),
        contentAlignment = Alignment.TopCenter,
    ) {
        val width = with(density) { maxWidth.toPx() }
        state.width = width
        if (state.isEmpty) {
            empty()
            return@BoxWithConstraints
        }
        val threshold = width * 0.35f
        val step = with(density) { 10.dp.toPx() }
        val shown = items.drop(state.index).take(visibleCount.coerceAtLeast(1))
        val progress = abs(state.progress)
        shown.indices.reversed().forEach { depth ->
            val item = shown[depth]
            val position = state.index + depth
            identity(key?.invoke(item) ?: position) {
                val top = depth == 0
                val effective = if (top) 0f else depth - progress
                Box(
                    modifier = Modifier
                        .graphicsLayer {
                            if (top) {
                                val offset = state.offset.value
                                translationX = offset.x
                                translationY = offset.y
                                rotationZ = (offset.x / width) * 12f
                                transformOrigin = TransformOrigin(0.5f, 1f)
                            } else {
                                val scale = 1f - 0.05f * effective
                                scaleX = scale
                                scaleY = scale
                                translationY = step * effective
                                transformOrigin = TransformOrigin(0.5f, 1f)
                                alpha = if (depth >= visibleCount - 1) lerp(0f, 1f, (visibleCount - effective).coerceIn(0f, 1f)) else 1f
                            }
                        }
                        .then(
                            if (top && enabled) {
                                Modifier
                                    .semantics {
                                        customActions = listOf(
                                            CustomAccessibilityAction("Swipe left") {
                                                state.swipe(CardStackDirection.Left)
                                                true
                                            },
                                            CustomAccessibilityAction("Swipe right") {
                                                state.swipe(CardStackDirection.Right)
                                                true
                                            },
                                        )
                                    }
                                    .pointerInput(state, position) {
                                        val tracker = VelocityTracker()
                                        detectDragGestures(
                                            onDragStart = { tracker.resetTracking() },
                                            onDrag = { change, amount ->
                                                if (state.busy) return@detectDragGestures
                                                change.consume()
                                                tracker.addPosition(change.uptimeMillis, change.position)
                                                scope.launch { state.offset.snapTo(state.offset.value + amount) }
                                            },
                                            onDragEnd = {
                                                if (state.busy) return@detectDragGestures
                                                val x = state.offset.value.x
                                                val velocity = tracker.calculateVelocity().x
                                                val fling = with(density) { 1000.dp.toPx() }
                                                scope.launch {
                                                    when {
                                                        x > threshold || (velocity > fling && x > 0f) -> state.dismiss(CardStackDirection.Right)
                                                        x < -threshold || (velocity < -fling && x < 0f) -> state.dismiss(CardStackDirection.Left)
                                                        else -> state.settle()
                                                    }
                                                }
                                            },
                                            onDragCancel = { scope.launch { state.settle() } },
                                        )
                                    }
                            } else {
                                Modifier
                            },
                        ),
                ) {
                    content(item)
                }
            }
        }
    }
}
```

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { CardStack, CardStackEmpty, CardStackActions, CardStackSwipeButton, CardStackUndo } from "@/components/interactions/card-stack"
```

```tsx
<Demo />
```

Pass `data` and `renderItem`, and handle `onSwipe(direction, item, index)`. Add children below the deck: `CardStackActions` with `CardStackSwipeButton`s and a `CardStackUndo`, and a `CardStackEmpty` shown once every card is swiped.

Undo brings back the last card from the side it left and calls `onUndo`. `useCardStack` exposes `index`, `count`, `remaining`, `canUndo`, `swipe` and `undo` for custom controls. The `CardStackDirection` type is exported.

## Examples

### Mid swipe

A static frame of the top card dragged to the right.

![Preview](https://ui.suiss.app/previews/interactions-card-stack--mid-swipe.light.webp)

```tsx
import { View } from "react-native"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Text } from "@/components/ui/text"

type Place = { id: string; name: string; city: string; detail: string }

const places: Place[] = [
  {
    id: "harbor",
    name: "Harbor Café",
    city: "Lisbon",
    detail: "Slow mornings, strong coffee and a view of the river.",
  },
  {
    id: "garden",
    name: "Garden Library",
    city: "Kyoto",
    detail: "Quiet reading rooms around a moss garden.",
  },
  {
    id: "market",
    name: "Night Market",
    city: "Taipei",
    detail: "Street food stalls open until two in the morning.",
  },
]

function PlaceCard({ place }: { place: Place }) {
  return (
    <Card className="h-full">
      <CardHeader>
        <CardTitle>{place.name}</CardTitle>
        <CardDescription>{place.city}</CardDescription>
      </CardHeader>
      <CardContent className="grow">
        <View className="grow rounded-xl bg-muted" />
      </CardContent>
      <CardContent>
        <Text className="text-sm text-muted-foreground">{place.detail}</Text>
      </CardContent>
    </Card>
  )
}

export function CardStackMidSwipeExample() {
  return (
    <View className="gap-6">
      <View className="h-96 w-full">
        <View
          className="absolute inset-0"
          style={{ transform: [{ translateY: 14.4 }, { scale: 0.94 }] }}
        >
          <PlaceCard place={places[2]} />
        </View>
        <View
          className="absolute inset-0"
          style={{ transform: [{ translateY: 2.4 }, { scale: 0.99 }] }}
        >
          <PlaceCard place={places[1]} />
        </View>
        <View
          className="absolute inset-0"
          style={{
            transform: [
              { translateX: 96 },
              { translateY: 12 },
              { rotate: "3deg" },
            ],
          }}
        >
          <PlaceCard place={places[0]} />
        </View>
      </View>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct CardStackMidSwipeExample: View {
  var body: some View {
    ZStack {
      CardStackPlaceCard(place: cardStackPlaces[2])
        .scaleEffect(0.94)
        .offset(y: 14.4)
      CardStackPlaceCard(place: cardStackPlaces[1])
        .scaleEffect(0.99)
        .offset(y: 2.4)
      CardStackPlaceCard(place: cardStackPlaces[0])
        .rotationEffect(.degrees(3))
        .offset(x: 96, y: 12)
    }
    .frame(height: 384)
  }
}

struct CardStackPlace: Identifiable {
  let id: String
  let name: String
  let city: String
  let detail: String
}

let cardStackPlaces = [
  CardStackPlace(id: "harbor", name: "Harbor Café", city: "Lisbon", detail: "Slow mornings, strong coffee and a view of the river."),
  CardStackPlace(id: "garden", name: "Garden Library", city: "Kyoto", detail: "Quiet reading rooms around a moss garden."),
  CardStackPlace(id: "market", name: "Night Market", city: "Taipei", detail: "Street food stalls open until two in the morning."),
]

struct CardStackPlaceCard: View {
  let place: CardStackPlace

  var body: some View {
    Card {
      CardHeader(LocalizedStringKey(place.name), description: LocalizedStringKey(place.city))
      CardContent {
        RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
          .fill(EsuissColor.muted)
          .frame(maxHeight: .infinity)
      }
      .frame(maxHeight: .infinity)
      CardContent {
        Text(verbatim: place.detail)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
      }
    }
    .frame(maxHeight: .infinity)
    .accessibilityElement(children: .combine)
    .accessibilityLabel(Text(verbatim: "\(place.name), \(place.city)"))
  }
}
```

```kotlin
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Card
import com.esuiss.ui.components.CardContent
import com.esuiss.ui.components.CardHeader
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

class CardStackPlace(val id: String, val name: String, val city: String, val detail: String)

val cardStackPlaces = listOf(
    CardStackPlace("harbor", "Harbor Café", "Lisbon", "Slow mornings, strong coffee and a view of the river."),
    CardStackPlace("garden", "Garden Library", "Kyoto", "Quiet reading rooms around a moss garden."),
    CardStackPlace("market", "Night Market", "Taipei", "Street food stalls open until two in the morning."),
)

val cardStackDeckHeight = 384.dp

@Composable
fun CardStackPlaceCard(place: CardStackPlace, modifier: Modifier = Modifier) {
    val colors = EsuissTheme.colors
    Card(modifier.fillMaxWidth().height(cardStackDeckHeight)) {
        CardHeader(place.name, description = place.city)
        CardContent(Modifier.weight(1f)) {
            Box(
                Modifier
                    .fillMaxSize()
                    .background(colors.muted, RoundedCornerShape(Radius.xl)),
            )
        }
        CardContent {
            Text(place.detail, style = Typography.sm.copy(color = colors.mutedForeground))
        }
    }
}

@Composable
fun CardStackMidSwipe() {
    Box(Modifier.fillMaxWidth().height(cardStackDeckHeight)) {
        CardStackPlaceCard(
            cardStackPlaces[2],
            Modifier.graphicsLayer {
                translationY = 14.4.dp.toPx()
                scaleX = 0.94f
                scaleY = 0.94f
            },
        )
        CardStackPlaceCard(
            cardStackPlaces[1],
            Modifier.graphicsLayer {
                translationY = 2.4.dp.toPx()
                scaleX = 0.99f
                scaleY = 0.99f
            },
        )
        CardStackPlaceCard(
            cardStackPlaces[0],
            Modifier.graphicsLayer {
                translationX = 96.dp.toPx()
                translationY = 12.dp.toPx()
                rotationZ = 3f
                transformOrigin = TransformOrigin.Center
            },
        )
    }
}
```

### Empty

An empty deck showing `CardStackEmpty`.

![Preview](https://ui.suiss.app/previews/interactions-card-stack--empty.light.webp)

```tsx
import { CardStack, CardStackActions, CardStackEmpty, CardStackSwipeButton, CardStackUndo } from "@/components/interactions/card-stack"

type Place = { id: string; name: string; city: string; detail: string }

export function CardStackEmptyExample() {
  return (
    <CardStack data={[] as Place[]} renderItem={() => null}>
      <CardStackEmpty>You have seen every place.</CardStackEmpty>
      <CardStackActions>
        <CardStackSwipeButton direction="left" />
        <CardStackUndo />
        <CardStackSwipeButton direction="right" />
      </CardStackActions>
    </CardStack>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct CardStackEmptyExample: View {
  var body: some View {
    CardStackDemo(places: [], showsLog: false)
  }
}

struct CardStackPlace: Identifiable {
  let id: String
  let name: String
  let city: String
  let detail: String
}

struct CardStackPlaceCard: View {
  let place: CardStackPlace

  var body: some View {
    Card {
      CardHeader(LocalizedStringKey(place.name), description: LocalizedStringKey(place.city))
      CardContent {
        RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
          .fill(EsuissColor.muted)
          .frame(maxHeight: .infinity)
      }
      .frame(maxHeight: .infinity)
      CardContent {
        Text(verbatim: place.detail)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
      }
    }
    .frame(maxHeight: .infinity)
    .accessibilityElement(children: .combine)
    .accessibilityLabel(Text(verbatim: "\(place.name), \(place.city)"))
  }
}

struct CardStackDemo: View {
  let places: [CardStackPlace]
  var showsLog = true

  @State private var controller = CardStackController()
  @State private var log = ""

  var body: some View {
    VStack(spacing: 12) {
      VStack(spacing: 24) {
        ZStack {
          if !controller.canSwipe {
            Text("You have seen every place.")
              .textStyle(.sm)
              .foregroundStyle(EsuissColor.mutedForeground)
              .multilineTextAlignment(.center)
              .padding(24)
              .frame(maxWidth: .infinity, maxHeight: .infinity)
              .overlay {
                RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
                  .strokeBorder(EsuissColor.border, style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
              }
          }
          CardStack(
            places,
            controller: controller,
            onSwipe: { direction, place in
              log = "\(direction == .left ? "Skipped" : "Liked") \(place.name)"
            },
            onUndo: { place in
              log = "Restored \(place.name)"
            }
          ) { place in
            CardStackPlaceCard(place: place)
          }
        }
        .frame(height: 384)
        HStack(spacing: 16) {
          CardStackButton(.swipeLeft, controller: controller)
          CardStackButton(.undo, controller: controller)
          CardStackButton(.swipeRight, controller: controller)
        }
      }
      if showsLog {
        Text(verbatim: log)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
          .multilineTextAlignment(.center)
          .frame(maxWidth: .infinity)
          .accessibilityAddTraits(.updatesFrequently)
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ArrowCounterClockwise
import com.adamglin.phosphoricons.regular.Heart
import com.adamglin.phosphoricons.regular.X
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.CardStack
import com.esuiss.ui.interactions.CardStackDirection
import com.esuiss.ui.interactions.CardStackState
import com.esuiss.ui.interactions.rememberCardStackState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

class CardStackPlace(val id: String, val name: String, val city: String, val detail: String)

val cardStackDeckHeight = 384.dp

@Composable
fun CardStackEmptyBox() {
    val colors = EsuissTheme.colors
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(cardStackDeckHeight)
            .drawBehind {
                val stroke = 1.dp.toPx()
                val radius = Radius.xl2.toPx()
                drawRoundRect(
                    color = colors.border,
                    topLeft = Offset(stroke / 2, stroke / 2),
                    size = Size(size.width - stroke, size.height - stroke),
                    cornerRadius = CornerRadius(radius, radius),
                    style = Stroke(width = stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(3.dp.toPx(), 3.dp.toPx()))),
                )
            }
            .padding(24.dp),
        contentAlignment = Alignment.Center,
    ) {
        Text(
            "You have seen every place.",
            style = Typography.sm.copy(color = colors.mutedForeground),
            textAlign = TextAlign.Center,
        )
    }
}

@Composable
fun CardStackActions(state: CardStackState, enabled: Boolean) {
    val colors = EsuissTheme.colors
    val canSwipe = enabled && !state.isEmpty
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Button(
            onClick = { state.swipe(CardStackDirection.Left) },
            modifier = Modifier.size(56.dp),
            variant = ButtonVariant.Outline,
            size = ButtonSize.IconLg,
            enabled = canSwipe,
        ) {
            Icon(PhosphorIcons.Regular.X, contentDescription = "Swipe left", size = 24.dp, tint = colors.destructive)
        }
        Button(
            onClick = { state.undo() },
            variant = ButtonVariant.Ghost,
            size = ButtonSize.IconLg,
            enabled = state.canUndo,
        ) {
            Icon(PhosphorIcons.Regular.ArrowCounterClockwise, contentDescription = "Undo")
        }
        Button(
            onClick = { state.swipe(CardStackDirection.Right) },
            modifier = Modifier.size(56.dp),
            variant = ButtonVariant.Outline,
            size = ButtonSize.IconLg,
            enabled = canSwipe,
        ) {
            Icon(PhosphorIcons.Regular.Heart, contentDescription = "Swipe right", size = 24.dp, tint = colors.primary)
        }
    }
}

@Composable
fun CardStackEmptyDemo() {
    val state = rememberCardStackState()
    Column(verticalArrangement = Arrangement.spacedBy(24.dp)) {
        CardStack(
            items = emptyList<CardStackPlace>(),
            onSwipe = { _, _ -> },
            state = state,
            empty = { CardStackEmptyBox() },
        ) { }
        CardStackActions(state, enabled = true)
    }
}
```

### Disabled

Turns off swiping and the buttons with `disabled`.

![Preview](https://ui.suiss.app/previews/interactions-card-stack--disabled.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { CardStack, CardStackActions, CardStackEmpty, CardStackSwipeButton, CardStackUndo } from "@/components/interactions/card-stack"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Text } from "@/components/ui/text"

type Place = { id: string; name: string; city: string; detail: string }

const places: Place[] = [
  {
    id: "harbor",
    name: "Harbor Café",
    city: "Lisbon",
    detail: "Slow mornings, strong coffee and a view of the river.",
  },
  {
    id: "garden",
    name: "Garden Library",
    city: "Kyoto",
    detail: "Quiet reading rooms around a moss garden.",
  },
  {
    id: "market",
    name: "Night Market",
    city: "Taipei",
    detail: "Street food stalls open until two in the morning.",
  },
]

function PlaceCard({ place }: { place: Place }) {
  return (
    <Card className="h-full">
      <CardHeader>
        <CardTitle>{place.name}</CardTitle>
        <CardDescription>{place.city}</CardDescription>
      </CardHeader>
      <CardContent className="grow">
        <View className="grow rounded-xl bg-muted" />
      </CardContent>
      <CardContent>
        <Text className="text-sm text-muted-foreground">{place.detail}</Text>
      </CardContent>
    </Card>
  )
}

function Demo({ disabled = false }: { disabled?: boolean }) {
  const [log, setLog] = React.useState("")
  return (
    <View className="gap-3">
      <CardStack
        data={places}
        keyExtractor={(place) => place.id}
        renderItem={(place) => <PlaceCard place={place} />}
        itemAccessibilityLabel={(place) => `${place.name}, ${place.city}`}
        disabled={disabled}
        onSwipe={(direction, place) =>
          setLog(`${direction === "left" ? "Skipped" : "Liked"} ${place.name}`)
        }
        onUndo={(place) => setLog(`Restored ${place.name}`)}
      >
        <CardStackEmpty>You have seen every place.</CardStackEmpty>
        <CardStackActions>
          <CardStackSwipeButton direction="left" />
          <CardStackUndo />
          <CardStackSwipeButton direction="right" />
        </CardStackActions>
      </CardStack>
      <Text role="status" className="text-center text-sm text-muted-foreground">
        {log}
      </Text>
    </View>
  )
}

export function CardStackDisabledExample() {
  return (
    <Demo disabled />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct CardStackDisabledExample: View {
  var body: some View {
    CardStackDemo(places: cardStackPlaces)
      .disabled(true)
  }
}

struct CardStackPlace: Identifiable {
  let id: String
  let name: String
  let city: String
  let detail: String
}

let cardStackPlaces = [
  CardStackPlace(id: "harbor", name: "Harbor Café", city: "Lisbon", detail: "Slow mornings, strong coffee and a view of the river."),
  CardStackPlace(id: "garden", name: "Garden Library", city: "Kyoto", detail: "Quiet reading rooms around a moss garden."),
  CardStackPlace(id: "market", name: "Night Market", city: "Taipei", detail: "Street food stalls open until two in the morning."),
]

struct CardStackPlaceCard: View {
  let place: CardStackPlace

  var body: some View {
    Card {
      CardHeader(LocalizedStringKey(place.name), description: LocalizedStringKey(place.city))
      CardContent {
        RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
          .fill(EsuissColor.muted)
          .frame(maxHeight: .infinity)
      }
      .frame(maxHeight: .infinity)
      CardContent {
        Text(verbatim: place.detail)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
      }
    }
    .frame(maxHeight: .infinity)
    .accessibilityElement(children: .combine)
    .accessibilityLabel(Text(verbatim: "\(place.name), \(place.city)"))
  }
}

struct CardStackDemo: View {
  let places: [CardStackPlace]
  var showsLog = true

  @State private var controller = CardStackController()
  @State private var log = ""

  var body: some View {
    VStack(spacing: 12) {
      VStack(spacing: 24) {
        ZStack {
          if !controller.canSwipe {
            Text("You have seen every place.")
              .textStyle(.sm)
              .foregroundStyle(EsuissColor.mutedForeground)
              .multilineTextAlignment(.center)
              .padding(24)
              .frame(maxWidth: .infinity, maxHeight: .infinity)
              .overlay {
                RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
                  .strokeBorder(EsuissColor.border, style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
              }
          }
          CardStack(
            places,
            controller: controller,
            onSwipe: { direction, place in
              log = "\(direction == .left ? "Skipped" : "Liked") \(place.name)"
            },
            onUndo: { place in
              log = "Restored \(place.name)"
            }
          ) { place in
            CardStackPlaceCard(place: place)
          }
        }
        .frame(height: 384)
        HStack(spacing: 16) {
          CardStackButton(.swipeLeft, controller: controller)
          CardStackButton(.undo, controller: controller)
          CardStackButton(.swipeRight, controller: controller)
        }
      }
      if showsLog {
        Text(verbatim: log)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
          .multilineTextAlignment(.center)
          .frame(maxWidth: .infinity)
          .accessibilityAddTraits(.updatesFrequently)
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ArrowCounterClockwise
import com.adamglin.phosphoricons.regular.Heart
import com.adamglin.phosphoricons.regular.X
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Card
import com.esuiss.ui.components.CardContent
import com.esuiss.ui.components.CardHeader
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.CardStack
import com.esuiss.ui.interactions.CardStackDirection
import com.esuiss.ui.interactions.CardStackState
import com.esuiss.ui.interactions.rememberCardStackState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

@Composable
fun CardStackDisabledExample() {
    CardStackDemo(enabled = false)
}

class CardStackPlace(val id: String, val name: String, val city: String, val detail: String)

val cardStackPlaces = listOf(
    CardStackPlace("harbor", "Harbor Café", "Lisbon", "Slow mornings, strong coffee and a view of the river."),
    CardStackPlace("garden", "Garden Library", "Kyoto", "Quiet reading rooms around a moss garden."),
    CardStackPlace("market", "Night Market", "Taipei", "Street food stalls open until two in the morning."),
)

val cardStackDeckHeight = 384.dp

@Composable
fun CardStackPlaceCard(place: CardStackPlace, modifier: Modifier = Modifier) {
    val colors = EsuissTheme.colors
    Card(modifier.fillMaxWidth().height(cardStackDeckHeight)) {
        CardHeader(place.name, description = place.city)
        CardContent(Modifier.weight(1f)) {
            Box(
                Modifier
                    .fillMaxSize()
                    .background(colors.muted, RoundedCornerShape(Radius.xl)),
            )
        }
        CardContent {
            Text(place.detail, style = Typography.sm.copy(color = colors.mutedForeground))
        }
    }
}

@Composable
fun CardStackEmptyBox() {
    val colors = EsuissTheme.colors
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(cardStackDeckHeight)
            .drawBehind {
                val stroke = 1.dp.toPx()
                val radius = Radius.xl2.toPx()
                drawRoundRect(
                    color = colors.border,
                    topLeft = Offset(stroke / 2, stroke / 2),
                    size = Size(size.width - stroke, size.height - stroke),
                    cornerRadius = CornerRadius(radius, radius),
                    style = Stroke(width = stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(3.dp.toPx(), 3.dp.toPx()))),
                )
            }
            .padding(24.dp),
        contentAlignment = Alignment.Center,
    ) {
        Text(
            "You have seen every place.",
            style = Typography.sm.copy(color = colors.mutedForeground),
            textAlign = TextAlign.Center,
        )
    }
}

@Composable
fun CardStackActions(state: CardStackState, enabled: Boolean) {
    val colors = EsuissTheme.colors
    val canSwipe = enabled && !state.isEmpty
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Button(
            onClick = { state.swipe(CardStackDirection.Left) },
            modifier = Modifier.size(56.dp),
            variant = ButtonVariant.Outline,
            size = ButtonSize.IconLg,
            enabled = canSwipe,
        ) {
            Icon(PhosphorIcons.Regular.X, contentDescription = "Swipe left", size = 24.dp, tint = colors.destructive)
        }
        Button(
            onClick = { state.undo() },
            variant = ButtonVariant.Ghost,
            size = ButtonSize.IconLg,
            enabled = state.canUndo,
        ) {
            Icon(PhosphorIcons.Regular.ArrowCounterClockwise, contentDescription = "Undo")
        }
        Button(
            onClick = { state.swipe(CardStackDirection.Right) },
            modifier = Modifier.size(56.dp),
            variant = ButtonVariant.Outline,
            size = ButtonSize.IconLg,
            enabled = canSwipe,
        ) {
            Icon(PhosphorIcons.Regular.Heart, contentDescription = "Swipe right", size = 24.dp, tint = colors.primary)
        }
    }
}

@Composable
fun CardStackDemo(enabled: Boolean) {
    var log by rememberSaveable { mutableStateOf("") }
    val state = rememberCardStackState()
    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Column(verticalArrangement = Arrangement.spacedBy(24.dp)) {
            CardStack(
                items = cardStackPlaces,
                onSwipe = { direction, place ->
                    log = "${if (direction == CardStackDirection.Left) "Skipped" else "Liked"} ${place.name}"
                },
                state = state,
                key = { it.id },
                enabled = enabled,
                onUndo = { _, place -> log = "Restored ${place.name}" },
                empty = { CardStackEmptyBox() },
            ) { place ->
                CardStackPlaceCard(place, Modifier.semantics(mergeDescendants = true) { contentDescription = "${place.name}, ${place.city}" })
            }
            CardStackActions(state, enabled)
        }
        Text(
            log,
            modifier = Modifier.fillMaxWidth().semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
            textAlign = TextAlign.Center,
        )
    }
}
```

## API Reference

### CardStack

The root that renders the deck and handles the drag gesture.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `data` | `readonly T[]` | Required | The items to show as cards. |
| `renderItem` | `(item: T, index: number) => React.ReactNode` | Required | Renders one card from the item and its index. |
| `keyExtractor` | `(item: T, index: number) => string` | - | Returns a key for a card. Defaults to its position. |
| `onSwipe` | `(direction: CardStackDirection, item: T, index: number) => void` | - | Called with the direction, item and index after a card leaves. |
| `onUndo` | `(item: T, index: number) => void` | - | Called with the item and index after undo. |
| `threshold` | `number` | `120` | Drag distance that commits a swipe. Defaults to `120`. |
| `visibleCount` | `number` | `3` | Cards stacked behind the top card. Defaults to `3`. |
| `disabled` | `boolean` | `false` | Turns off the gesture and the swipe buttons. |
| `itemAccessibilityLabel` | `(item: T, index: number) => string` | - | Returns the label for the top card. |
| `deckClassName` | `string` | - | Classes for the deck area, which is `h-96` by default. |

Also accepts the props of `ViewProps`.

### CardStackEmpty

A dashed placeholder shown when no cards remain.

Also accepts the props of `ViewProps`, `React.RefAttributes<View>`.

### CardStackActions

A centered row for the buttons.

Also accepts the props of `ViewProps`, `React.RefAttributes<View>`.

### CardStackSwipeButton

A round button that swipes the top card left or right.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `direction` | `CardStackDirection` | Required | `left` shows an X icon, `right` shows a heart icon. |

Also accepts the props of `React.ComponentProps<typeof Button>`.

### CardStackUndo

A button that restores the last swiped card.

Also accepts the props of `React.ComponentProps<typeof Button>`.

## Accessibility

- The top card has `Swipe left` and `Swipe right` accessibility actions.
- Cards behind the top card are hidden from screen readers.
- Swipe buttons are labelled `Swipe left` and `Swipe right`, and undo is labelled `Undo`, unless you pass `accessibilityLabel`.

## Notes

- A medium impact haptic plays when a card is swiped by gesture, button or accessibility action, if the app provides an adapter through `HapticsProvider`.
- Fly-out and spring animations respect the system reduce motion setting.
- The left button icon uses `text-destructive`; the right uses `text-primary`.

