# Empty Bag

An empty shopping bag state with sign-in and shopping actions above a row of recommended products.

Empty Bag is an open-source block from suiss/ui for React, 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/blocks/empty-bag

**React**

**Example**

```tsx title="components/blocks/empty-bag.tsx"
"use client"

import { CheckIcon, HandbagIcon, PlusIcon } from "@phosphor-icons/react"
import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?w=500&q=80&auto=format&fit=crop`

const recommended = [
  {
    name: "Pods Studio",
    price: "$549",
    image: photo("1599669454699-248893623440"),
  },
  {
    name: "Sport Band",
    price: "$49",
    image: photo("1624096104992-9b4fa3a279dd"),
  },
  {
    name: "Phone Pro Clear Case",
    price: "$49",
    image: photo("1703676311066-34caf2cbee33"),
  },
]

export function EmptyBag() {
  const [added, setAdded] = React.useState<string[]>([])
  const count = added.length

  return (
    <section className="flex flex-col items-center gap-5.5 px-5 py-10 md:px-12">
      <Empty className="gap-5 p-0">
        <EmptyHeader className="max-w-md gap-2.5">
          <EmptyMedia className="relative mb-1">
            <HandbagIcon className="size-11.5" />
            {count > 0 && (
              <span className="absolute -end-1.5 -bottom-0.5 flex size-5 items-center justify-center rounded-full bg-foreground text-xs font-semibold text-background tabular-nums transition-[scale] duration-300 ease-[cubic-bezier(0.3,1.25,0.5,1)] starting:scale-0">
                {count}
              </span>
            )}
          </EmptyMedia>
          <EmptyTitle className="text-4xl tracking-tight" aria-live="polite">
            {count
              ? `There ${count === 1 ? "is 1 item" : `are ${count} items`} in your bag.`
              : "Your bag is empty."}
          </EmptyTitle>
          <EmptyDescription className="text-lg/normal">
            {count
              ? "Check out now or keep browsing."
              : "Sign in to see items you saved."}
          </EmptyDescription>
        </EmptyHeader>
        <EmptyContent className="flex-row flex-wrap justify-center gap-3.5">
          <Button size="lg">{count ? "Check Out" : "Sign In"}</Button>
          <Button size="lg" variant="outline">
            Continue Shopping
          </Button>
        </EmptyContent>
      </Empty>
      <div className="mt-3.5 flex w-full max-w-190 flex-col gap-4">
        <h2 className="text-lg font-semibold">You may also like</h2>
        <ul className="grid grid-cols-1 gap-4 sm:grid-cols-3">
          {recommended.map((item) => {
            const inBag = added.includes(item.name)
            return (
              <li key={item.name} className="flex flex-col gap-2.5">
                <img
                  src={item.image}
                  alt=""
                  className="aspect-[1.2] w-full rounded-2xl bg-secondary object-cover"
                />
                <div className="flex items-center gap-2">
                  <div className="flex flex-1 flex-col">
                    <span className="text-base font-semibold">{item.name}</span>
                    <span className="text-sm text-muted-foreground">
                      {item.price}
                    </span>
                  </div>
                  <Button
                    size="icon-sm"
                    aria-pressed={inBag}
                    aria-label={
                      inBag
                        ? `Remove ${item.name} from bag`
                        : `Add ${item.name} to bag`
                    }
                    onClick={() =>
                      setAdded((current) =>
                        inBag
                          ? current.filter((name) => name !== item.name)
                          : [...current, item.name]
                      )
                    }
                    data-in-bag={inBag ? "" : undefined}
                    className="relative size-7.5 transition-colors duration-250 after:absolute after:-inset-1.75 after:content-[''] data-in-bag:bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_40%)] dark:data-in-bag:bg-[color-mix(in_oklab,var(--color-green-500),black_45%)]"
                  >
                    {inBag ? (
                      <CheckIcon className="size-3.75" />
                    ) : (
                      <PlusIcon className="size-3.75" />
                    )}
                  </Button>
                </div>
              </li>
            )
          })}
        </ul>
      </div>
    </section>
  )
}
```

An empty shopping bag state with a short message, Sign In and Continue Shopping buttons and a row of recommended products. Adding a recommendation updates the bag icon's count and switches the message and primary action to a checkout state. Recommendations sit in one column on mobile and three on larger screens. Use it for empty carts, wish lists or saved-items screens.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/empty-bag
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add blocks empty-bag
```

### Manual

1. Install the dependencies:

```bash title="Terminal"
npm install @phosphor-icons/react
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Empty](https://ui.suiss.app/docs/components/empty).

3. Copy the source into `components/blocks/empty-bag.tsx`:

```tsx title="components/blocks/empty-bag.tsx"
"use client"

import { CheckIcon, HandbagIcon, PlusIcon } from "@phosphor-icons/react"
import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?w=500&q=80&auto=format&fit=crop`

const recommended = [
  {
    name: "Pods Studio",
    price: "$549",
    image: photo("1599669454699-248893623440"),
  },
  {
    name: "Sport Band",
    price: "$49",
    image: photo("1624096104992-9b4fa3a279dd"),
  },
  {
    name: "Phone Pro Clear Case",
    price: "$49",
    image: photo("1703676311066-34caf2cbee33"),
  },
]

export function EmptyBag() {
  const [added, setAdded] = React.useState<string[]>([])
  const count = added.length

  return (
    <section className="flex flex-col items-center gap-5.5 px-5 py-10 md:px-12">
      <Empty className="gap-5 p-0">
        <EmptyHeader className="max-w-md gap-2.5">
          <EmptyMedia className="relative mb-1">
            <HandbagIcon className="size-11.5" />
            {count > 0 && (
              <span className="absolute -end-1.5 -bottom-0.5 flex size-5 items-center justify-center rounded-full bg-foreground text-xs font-semibold text-background tabular-nums transition-[scale] duration-300 ease-[cubic-bezier(0.3,1.25,0.5,1)] starting:scale-0">
                {count}
              </span>
            )}
          </EmptyMedia>
          <EmptyTitle className="text-4xl tracking-tight" aria-live="polite">
            {count
              ? `There ${count === 1 ? "is 1 item" : `are ${count} items`} in your bag.`
              : "Your bag is empty."}
          </EmptyTitle>
          <EmptyDescription className="text-lg/normal">
            {count
              ? "Check out now or keep browsing."
              : "Sign in to see items you saved."}
          </EmptyDescription>
        </EmptyHeader>
        <EmptyContent className="flex-row flex-wrap justify-center gap-3.5">
          <Button size="lg">{count ? "Check Out" : "Sign In"}</Button>
          <Button size="lg" variant="outline">
            Continue Shopping
          </Button>
        </EmptyContent>
      </Empty>
      <div className="mt-3.5 flex w-full max-w-190 flex-col gap-4">
        <h2 className="text-lg font-semibold">You may also like</h2>
        <ul className="grid grid-cols-1 gap-4 sm:grid-cols-3">
          {recommended.map((item) => {
            const inBag = added.includes(item.name)
            return (
              <li key={item.name} className="flex flex-col gap-2.5">
                <img
                  src={item.image}
                  alt=""
                  className="aspect-[1.2] w-full rounded-2xl bg-secondary object-cover"
                />
                <div className="flex items-center gap-2">
                  <div className="flex flex-1 flex-col">
                    <span className="text-base font-semibold">{item.name}</span>
                    <span className="text-sm text-muted-foreground">
                      {item.price}
                    </span>
                  </div>
                  <Button
                    size="icon-sm"
                    aria-pressed={inBag}
                    aria-label={
                      inBag
                        ? `Remove ${item.name} from bag`
                        : `Add ${item.name} to bag`
                    }
                    onClick={() =>
                      setAdded((current) =>
                        inBag
                          ? current.filter((name) => name !== item.name)
                          : [...current, item.name]
                      )
                    }
                    data-in-bag={inBag ? "" : undefined}
                    className="relative size-7.5 transition-colors duration-250 after:absolute after:-inset-1.75 after:content-[''] data-in-bag:bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_40%)] dark:data-in-bag:bg-[color-mix(in_oklab,var(--color-green-500),black_45%)]"
                  >
                    {inBag ? (
                      <CheckIcon className="size-3.75" />
                    ) : (
                      <PlusIcon className="size-3.75" />
                    )}
                  </Button>
                </div>
              </li>
            )
          })}
        </ul>
      </div>
    </section>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { EmptyBag } from "@/components/blocks/empty-bag"
```

```tsx
<EmptyBag />
```

Replace `recommended` with products from your recommendation service, each with a name, formatted price and image. Swap the local add toggle for your cart logic, and connect Sign In, Check Out and Continue Shopping to your routes.

## Notes

Add buttons expose their pressed state and name the product. Product photos are Unsplash placeholders.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/blocks-empty-bag--default.light.webp)

```tsx title="uniwind/components/blocks/empty-bag.tsx"
import { cn } from "cn"
import { CheckIcon, HandbagIcon, PlusIcon } from "phosphor-react-native"
import * as React from "react"
import { Image, ScrollView, View } from "react-native"
import { ReduceMotion, ZoomIn } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?w=500&q=80&auto=format&fit=crop`

const RECOMMENDED = [
  {
    name: "Pods Studio",
    price: "$549",
    image: photo("1599669454699-248893623440"),
  },
  {
    name: "Sport Band",
    price: "$49",
    image: photo("1624096104992-9b4fa3a279dd"),
  },
  {
    name: "Phone Pro Clear Case",
    price: "$49",
    image: photo("1703676311066-34caf2cbee33"),
  },
]

function EmptyBag() {
  const [added, setAdded] = React.useState<string[]>([])
  const count = added.length

  return (
    <Screen>
      <ScrollView contentContainerClassName="gap-5.5 py-10">
        <Empty className="flex-none gap-5 px-5 py-0">
          <EmptyHeader className="max-w-md gap-2.5">
            <EmptyMedia className="mb-1">
              <Icon as={HandbagIcon} weight="light" className="size-11.5" />
              {count > 0 ? (
                <AnimatedView
                  key={count}
                  entering={ZoomIn.duration(300).reduceMotion(
                    ReduceMotion.System
                  )}
                  className="absolute -end-1.5 -bottom-0.5 size-5 items-center justify-center rounded-full bg-foreground"
                >
                  <Text className="text-xs font-semibold text-background tabular-nums">
                    {count}
                  </Text>
                </AnimatedView>
              ) : null}
            </EmptyMedia>
            <EmptyTitle
              accessibilityLiveRegion="polite"
              className="text-3xl font-semibold"
            >
              {count
                ? `There ${count === 1 ? "is 1 item" : `are ${count} items`} in your bag.`
                : "Your bag is empty."}
            </EmptyTitle>
            <EmptyDescription className="text-lg">
              {count
                ? "Check out now or keep browsing."
                : "Sign in to see items you saved."}
            </EmptyDescription>
          </EmptyHeader>
          <EmptyContent className="flex-row flex-wrap justify-center gap-3.5">
            <Button size="lg">
              <Text>{count ? "Check Out" : "Sign In"}</Text>
            </Button>
            <Button size="lg" variant="outline">
              <Text>Continue Shopping</Text>
            </Button>
          </EmptyContent>
        </Empty>
        <View className="mt-3.5 gap-4">
          <Text role="heading" className="px-5 text-lg font-semibold">
            You may also like
          </Text>
          <ScrollView
            horizontal
            showsHorizontalScrollIndicator={false}
            contentContainerClassName="gap-4 px-5"
          >
            {RECOMMENDED.map((item) => {
              const inBag = added.includes(item.name)
              return (
                <View key={item.name} className="w-60 gap-2.5">
                  <Image
                    source={{ uri: item.image }}
                    resizeMode="cover"
                    accessibilityIgnoresInvertColors
                    className="aspect-[1.2] w-full rounded-2xl bg-secondary"
                  />
                  <View className="flex-row items-center gap-2">
                    <View className="flex-1">
                      <Text className="text-base font-semibold">
                        {item.name}
                      </Text>
                      <Text className="text-sm text-muted-foreground">
                        {item.price}
                      </Text>
                    </View>
                    <Button
                      size="icon-sm"
                      hitSlop={7}
                      accessibilityState={{ selected: inBag }}
                      accessibilityLabel={
                        inBag
                          ? `Remove ${item.name} from bag`
                          : `Add ${item.name} to bag`
                      }
                      onPress={() =>
                        setAdded((current) =>
                          inBag
                            ? current.filter((name) => name !== item.name)
                            : [...current, item.name]
                        )
                      }
                      className={cn(
                        "size-7.5",
                        inBag && "bg-green-500 active:opacity-80"
                      )}
                    >
                      <Icon
                        as={inBag ? CheckIcon : PlusIcon}
                        weight="bold"
                        className="size-3.75"
                      />
                    </Button>
                  </View>
                </View>
              )
            })}
          </ScrollView>
        </View>
      </ScrollView>
    </Screen>
  )
}

export { EmptyBag }
```

```swift title="swift/Blocks/EmptyBag.swift"
import PhosphorSwift
import SwiftUI

public struct EmptyBag: View {
  struct Product: Identifiable {
    let name: String
    let price: String
    let image: URL?

    var id: String { name }
  }

  static func photo(_ id: String) -> URL? {
    URL(string: "https://images.unsplash.com/photo-\(id)?w=500&q=80&auto=format&fit=crop")
  }

  static var recommended: [Product] {
    [
      Product(name: "Pods Studio", price: "$549", image: photo("1599669454699-248893623440")),
      Product(name: "Sport Band", price: "$49", image: photo("1624096104992-9b4fa3a279dd")),
      Product(name: "Phone Pro Clear Case", price: "$49", image: photo("1703676311066-34caf2cbee33")),
    ]
  }

  @State private var added: [String]
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init() {
    self.init(added: [])
  }

  init(added: [String]) {
    _added = State(initialValue: added)
  }

  public var body: some View {
    let count = added.count
    Screen {
      KeyboardScrollView {
        VStack(spacing: 36) {
          Empty {
            EmptyHeader {
              EmptyMedia {
                Ph.handbag.light
                  .frame(width: 46, height: 46)
                  .foregroundStyle(EsuissColor.foreground)
                  .overlay(alignment: .bottomTrailing) {
                    if count > 0 {
                      Text(verbatim: "\(count)")
                        .font(.system(size: TextStyle.xs.size, weight: .semibold))
                        .monospacedDigit()
                        .foregroundStyle(EsuissColor.background)
                        .frame(width: 20, height: 20)
                        .background(EsuissColor.foreground, in: Circle())
                        .offset(x: 6, y: 2)
                        .transition(.scale)
                    }
                  }
              }
              EmptyTitle(count == 0 ? "Your bag is empty." : count == 1 ? "There is 1 item in your bag." : "There are \(count) items in your bag.")
              EmptyDescription(count > 0 ? "Check out now or keep browsing." : "Sign in to see items you saved.")
            }
            EmptyContent {
              HStack(spacing: 14) {
                Button(count > 0 ? "Check Out" : "Sign In") {}
                  .buttonStyle(.variant(.default, size: .lg))
                Button("Continue Shopping") {}
                  .buttonStyle(.variant(.outline, size: .lg))
              }
            }
          }
          .padding(-28)
          VStack(alignment: .leading, spacing: 16) {
            Text("You may also like")
              .scaledFont(.lg, weight: .semibold)
              .padding(.horizontal, 20)
              .accessibilityAddTraits(.isHeader)
            ScrollView(.horizontal) {
              HStack(alignment: .top, spacing: 16) {
                ForEach(Self.recommended) { item in
                  EmptyBagProduct(item: item, inBag: added.contains(item.name)) {
                    if let index = added.firstIndex(of: item.name) {
                      added.remove(at: index)
                    } else {
                      added.append(item.name)
                    }
                  }
                }
              }
              .padding(.horizontal, 20)
            }
            .scrollIndicators(.hidden)
          }
        }
        .padding(.top, 72)
        .padding(.bottom, 24)
      }
    }
    .animation(reduceMotion ? nil : .spring(response: 0.3, dampingFraction: 0.6), value: count)
  }
}

private struct EmptyBagProduct: View {
  let item: EmptyBag.Product
  let inBag: Bool
  let action: () -> Void

  var body: some View {
    VStack(alignment: .leading, spacing: 10) {
      EsuissColor.secondary
        .overlay {
          AsyncImage(url: item.image) { phase in
            if let image = phase.image {
              image.resizable().scaledToFill()
            }
          }
        }
        .frame(width: 220, height: 184)
        .clipShape(RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
        .accessibilityHidden(true)
      HStack(spacing: 8) {
        VStack(alignment: .leading, spacing: 0) {
          Text(verbatim: item.name)
            .scaledFont(.base, weight: .semibold)
            .lineLimit(1)
          Text(verbatim: item.price)
            .scaledFont(.sm)
            .foregroundStyle(EsuissColor.mutedForeground)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        Button(action: action) {
          (inBag ? Ph.check.bold : Ph.plus.bold)
            .frame(width: 15, height: 15)
            .foregroundStyle(EsuissColor.primaryForeground)
            .frame(width: 30, height: 30)
            .background(inBag ? Palette.green500 : EsuissColor.primary, in: Circle())
            .frame(width: 44, height: 44)
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .padding(.trailing, -7)
        .accessibilityLabel(inBag ? Text("Remove \(item.name) from bag") : Text("Add \(item.name) to bag"))
        .accessibilityAddTraits(inBag ? .isSelected : [])
      }
    }
    .frame(width: 220)
  }
}
```

```kt title="kotlin/blocks/EmptyBag.kt"
package com.esuiss.ui.blocks

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Light
import com.adamglin.phosphoricons.bold.Check
import com.adamglin.phosphoricons.bold.Plus
import com.adamglin.phosphoricons.light.DeviceMobile
import com.adamglin.phosphoricons.light.Handbag
import com.adamglin.phosphoricons.light.Headphones
import com.adamglin.phosphoricons.light.Watch
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Empty
import com.esuiss.ui.components.EmptyContent
import com.esuiss.ui.components.EmptyHeader
import com.esuiss.ui.components.EmptyMedia
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Screen
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Typography

private val PopEasing = CubicBezierEasing(0.3f, 1.25f, 0.5f, 1f)

private class Product(val name: String, val price: String, val icon: ImageVector)

private val Recommended = listOf(
    Product("Pods Studio", "$549", PhosphorIcons.Light.Headphones),
    Product("Sport Band", "$49", PhosphorIcons.Light.Watch),
    Product("Phone Pro Clear Case", "$49", PhosphorIcons.Light.DeviceMobile),
)

@Composable
fun EmptyBag(
    modifier: Modifier = Modifier,
) {
    val colors = EsuissTheme.colors
    val added = remember { mutableStateListOf<String>() }
    val count = added.size

    Screen(modifier) {
        Column(
            Modifier
                .fillMaxSize()
                .verticalScroll(rememberScrollState())
                .padding(vertical = 16.dp),
        ) {
            Empty {
                EmptyHeader {
                    EmptyMedia {
                        Icon(PhosphorIcons.Light.Handbag, contentDescription = null, size = 46.dp, tint = colors.foreground)
                        CountBadge(
                            count,
                            Modifier
                                .align(Alignment.BottomEnd)
                                .offset(x = 6.dp, y = 2.dp),
                        )
                    }
                    BasicText(
                        when (count) {
                            0 -> "Your bag is empty."
                            1 -> "There is 1 item in your bag."
                            else -> "There are $count items in your bag."
                        },
                        modifier = Modifier.semantics {
                            heading()
                            liveRegion = LiveRegionMode.Polite
                        },
                        style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center),
                    )
                    BasicText(
                        if (count > 0) "Check out now or keep browsing." else "Sign in to see items you saved.",
                        style = Typography.lg.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                    )
                }
                EmptyContent {
                    FlowRow(
                        horizontalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterHorizontally),
                        verticalArrangement = Arrangement.spacedBy(14.dp),
                    ) {
                        if (count > 0) {
                            Button("Check Out", {}, size = ButtonSize.Lg)
                        } else {
                            Button("Sign In", {}, size = ButtonSize.Lg)
                        }
                        Button("Continue Shopping", {}, variant = ButtonVariant.Outline, size = ButtonSize.Lg)
                    }
                }
            }
            BasicText(
                "You may also like",
                modifier = Modifier
                    .padding(horizontal = 20.dp)
                    .semantics { heading() },
                style = Typography.lg.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
            )
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .horizontalScroll(rememberScrollState())
                    .padding(PaddingValues(horizontal = 20.dp, vertical = 16.dp)),
                horizontalArrangement = Arrangement.spacedBy(16.dp),
            ) {
                Recommended.forEach { product ->
                    val inBag = product.name in added
                    ProductCard(product, inBag) {
                        if (inBag) added.remove(product.name) else added.add(product.name)
                    }
                }
            }
        }
    }
}

@Composable
private fun CountBadge(count: Int, modifier: Modifier) {
    val colors = EsuissTheme.colors
    AnimatedVisibility(
        visible = count > 0,
        modifier = modifier,
        enter = scaleIn(tween(300, easing = PopEasing)),
        exit = scaleOut(tween(150)),
    ) {
        Box(
            Modifier
                .size(20.dp)
                .background(colors.foreground, CircleShape),
            contentAlignment = Alignment.Center,
        ) {
            BasicText(
                count.toString(),
                style = Typography.xs.copy(color = colors.background, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum"),
            )
        }
    }
}

@Composable
private fun ProductCard(product: Product, inBag: Boolean, onToggle: () -> Unit) {
    val colors = EsuissTheme.colors
    Column(Modifier.width(220.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
        Box(
            Modifier
                .fillMaxWidth()
                .aspectRatio(1.2f)
                .background(colors.muted, RoundedCornerShape(16.dp))
                .clearAndSetSemantics {},
            contentAlignment = Alignment.Center,
        ) {
            Icon(product.icon, contentDescription = null, size = 64.dp, tint = colors.mutedForeground)
        }
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
            Column(Modifier.weight(1f)) {
                BasicText(product.name, style = Typography.base.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold))
                BasicText(product.price, style = Typography.sm.copy(color = colors.mutedForeground))
            }
            AddButton(product.name, inBag, onToggle)
        }
    }
}

@Composable
private fun AddButton(name: String, inBag: Boolean, onToggle: () -> Unit) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val fill by animateColorAsState(
        if (inBag) Palette.green500 else colors.primary,
        tween(250),
        label = "fill",
    )
    Box(
        Modifier
            .size(44.dp)
            .alpha(if (pressed) 0.8f else 1f)
            .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onToggle)
            .semantics {
                contentDescription = if (inBag) "Remove $name from bag" else "Add $name to bag"
                stateDescription = if (inBag) "In bag" else "Not in bag"
            },
        contentAlignment = Alignment.Center,
    ) {
        Box(
            Modifier
                .size(30.dp)
                .graphicsLayer {
                    val scale = if (pressed) 0.92f else 1f
                    scaleX = scale
                    scaleY = scale
                }
                .background(fill, CircleShape),
            contentAlignment = Alignment.Center,
        ) {
            Icon(
                if (inBag) PhosphorIcons.Bold.Check else PhosphorIcons.Bold.Plus,
                contentDescription = null,
                size = 15.dp,
                tint = colors.primaryForeground,
            )
        }
    }
}
```

Composes `Screen`, `Empty`, `Button`, `Icon` and `Text`. Adding a recommended product with its plus button increments a badge on the bag icon and changes the title and primary button to a checkout prompt.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/empty-bag
```

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 empty-bag
```

**SwiftUI**

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

**Jetpack Compose**

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

The CLI also adds what it builds on: [Button](https://ui.suiss.app/docs/components/button), [Empty](https://ui.suiss.app/docs/components/empty), [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [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-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Empty](https://ui.suiss.app/docs/components/empty), [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), 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/blocks/empty-bag.tsx`:

- `uniwind/components/blocks/empty-bag.tsx`: shown above

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-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Empty](https://ui.suiss.app/docs/components/empty), [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), 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/blocks/empty-bag.tsx`:

```tsx title="nativewind/components/blocks/empty-bag.tsx"
import { cn } from "cn"
import { CheckIcon, HandbagIcon, PlusIcon } from "phosphor-react-native"
import * as React from "react"
import { Image, ScrollView, View } from "react-native"
import { ReduceMotion, ZoomIn } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?w=500&q=80&auto=format&fit=crop`

const RECOMMENDED = [
  {
    name: "Pods Studio",
    price: "$549",
    image: photo("1599669454699-248893623440"),
  },
  {
    name: "Sport Band",
    price: "$49",
    image: photo("1624096104992-9b4fa3a279dd"),
  },
  {
    name: "Phone Pro Clear Case",
    price: "$49",
    image: photo("1703676311066-34caf2cbee33"),
  },
]

function EmptyBag() {
  const [added, setAdded] = React.useState<string[]>([])
  const count = added.length

  return (
    <Screen>
      <ScrollView contentContainerClassName="gap-[1.375rem] py-10">
        <Empty className="flex-none gap-5 px-5 py-0">
          <EmptyHeader className="max-w-md gap-2.5">
            <EmptyMedia className="mb-1">
              <Icon
                as={HandbagIcon}
                weight="light"
                className="size-[2.875rem]"
              />
              {count > 0 ? (
                <AnimatedView
                  key={count}
                  entering={ZoomIn.duration(300).reduceMotion(
                    ReduceMotion.System
                  )}
                  className="absolute -end-1.5 -bottom-0.5 size-5 items-center justify-center rounded-full bg-foreground"
                >
                  <Text className="text-xs font-semibold text-background tabular-nums">
                    {count}
                  </Text>
                </AnimatedView>
              ) : null}
            </EmptyMedia>
            <EmptyTitle
              accessibilityLiveRegion="polite"
              className="text-3xl font-semibold"
            >
              {count
                ? `There ${count === 1 ? "is 1 item" : `are ${count} items`} in your bag.`
                : "Your bag is empty."}
            </EmptyTitle>
            <EmptyDescription className="text-lg">
              {count
                ? "Check out now or keep browsing."
                : "Sign in to see items you saved."}
            </EmptyDescription>
          </EmptyHeader>
          <EmptyContent className="flex-row flex-wrap justify-center gap-3.5">
            <Button size="lg">
              <Text>{count ? "Check Out" : "Sign In"}</Text>
            </Button>
            <Button size="lg" variant="outline">
              <Text>Continue Shopping</Text>
            </Button>
          </EmptyContent>
        </Empty>
        <View className="mt-3.5 gap-4">
          <Text role="heading" className="px-5 text-lg font-semibold">
            You may also like
          </Text>
          <ScrollView
            horizontal
            showsHorizontalScrollIndicator={false}
            contentContainerClassName="gap-4 px-5"
          >
            {RECOMMENDED.map((item) => {
              const inBag = added.includes(item.name)
              return (
                <View key={item.name} className="w-60 gap-2.5">
                  <Image
                    source={{ uri: item.image }}
                    resizeMode="cover"
                    accessibilityIgnoresInvertColors
                    className="aspect-[1.2] w-full rounded-2xl bg-secondary"
                  />
                  <View className="flex-row items-center gap-2">
                    <View className="flex-1">
                      <Text className="text-base font-semibold">
                        {item.name}
                      </Text>
                      <Text className="text-sm text-muted-foreground">
                        {item.price}
                      </Text>
                    </View>
                    <Button
                      size="icon-sm"
                      hitSlop={7}
                      accessibilityState={{ selected: inBag }}
                      accessibilityLabel={
                        inBag
                          ? `Remove ${item.name} from bag`
                          : `Add ${item.name} to bag`
                      }
                      onPress={() =>
                        setAdded((current) =>
                          inBag
                            ? current.filter((name) => name !== item.name)
                            : [...current, item.name]
                        )
                      }
                      className={cn(
                        "size-[1.875rem]",
                        inBag && "bg-green-500 active:opacity-80"
                      )}
                    >
                      <Icon
                        as={inBag ? CheckIcon : PlusIcon}
                        weight="bold"
                        className="size-[0.9375rem]"
                      />
                    </Button>
                  </View>
                </View>
              )
            })}
          </ScrollView>
        </View>
      </ScrollView>
    </Screen>
  )
}

export { EmptyBag }
```

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-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Empty](https://ui.suiss.app/docs/components/empty), [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), 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/blocks/empty-bag.tsx`:

```tsx title="stylesheet/components/blocks/empty-bag.tsx"
import { CheckIcon, HandbagIcon, PlusIcon } from "phosphor-react-native"
import * as React from "react"
import { Image, ScrollView, StyleSheet, View } from "react-native"
import { ReduceMotion, ZoomIn } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import { fontSize, palette, radius, useTheme } from "@/components/ui/theme"

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?w=500&q=80&auto=format&fit=crop`

const RECOMMENDED = [
  {
    name: "Pods Studio",
    price: "$549",
    image: photo("1599669454699-248893623440"),
  },
  {
    name: "Sport Band",
    price: "$49",
    image: photo("1624096104992-9b4fa3a279dd"),
  },
  {
    name: "Phone Pro Clear Case",
    price: "$49",
    image: photo("1703676311066-34caf2cbee33"),
  },
]

const styles = StyleSheet.create({
  content: { gap: 22, paddingVertical: 40 },
  empty: { flex: 0, gap: 20, paddingHorizontal: 20, paddingVertical: 0 },
  header: { maxWidth: 448, gap: 10 },
  media: { marginBottom: 4 },
  badge: {
    position: "absolute",
    end: -6,
    bottom: -2,
    width: 20,
    height: 20,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
  },
  badgeText: {
    ...fontSize.xs,
    fontWeight: "600",
    fontVariant: ["tabular-nums"],
  },
  title: { ...fontSize["3xl"], fontWeight: "600" },
  description: { ...fontSize.lg },
  actions: {
    flexDirection: "row",
    flexWrap: "wrap",
    justifyContent: "center",
    gap: 14,
  },
  section: { marginTop: 14, gap: 16 },
  heading: { ...fontSize.lg, fontWeight: "600", paddingHorizontal: 20 },
  rail: { gap: 16, paddingHorizontal: 20 },
  card: { width: 240, gap: 10 },
  image: { aspectRatio: 1.2, width: "100%", borderRadius: radius["2xl"] },
  row: { flexDirection: "row", alignItems: "center", gap: 8 },
  info: { flex: 1 },
  name: { ...fontSize.base, fontWeight: "600" },
  price: { ...fontSize.sm },
  add: { width: 30, height: 30 },
})

function EmptyBag() {
  const { colors } = useTheme()
  const [added, setAdded] = React.useState<string[]>([])
  const count = added.length

  return (
    <Screen>
      <ScrollView contentContainerStyle={styles.content}>
        <Empty style={styles.empty}>
          <EmptyHeader style={styles.header}>
            <EmptyMedia style={styles.media}>
              <Icon as={HandbagIcon} weight="light" size={46} />
              {count > 0 ? (
                <AnimatedView
                  key={count}
                  entering={ZoomIn.duration(300).reduceMotion(
                    ReduceMotion.System
                  )}
                  style={[styles.badge, { backgroundColor: colors.foreground }]}
                >
                  <Text
                    style={[styles.badgeText, { color: colors.background }]}
                  >
                    {count}
                  </Text>
                </AnimatedView>
              ) : null}
            </EmptyMedia>
            <EmptyTitle accessibilityLiveRegion="polite" style={styles.title}>
              {count
                ? `There ${count === 1 ? "is 1 item" : `are ${count} items`} in your bag.`
                : "Your bag is empty."}
            </EmptyTitle>
            <EmptyDescription style={styles.description}>
              {count
                ? "Check out now or keep browsing."
                : "Sign in to see items you saved."}
            </EmptyDescription>
          </EmptyHeader>
          <EmptyContent style={styles.actions}>
            <Button size="lg">
              <Text>{count ? "Check Out" : "Sign In"}</Text>
            </Button>
            <Button size="lg" variant="outline">
              <Text>Continue Shopping</Text>
            </Button>
          </EmptyContent>
        </Empty>
        <View style={styles.section}>
          <Text role="heading" style={styles.heading}>
            You may also like
          </Text>
          <ScrollView
            horizontal
            showsHorizontalScrollIndicator={false}
            contentContainerStyle={styles.rail}
          >
            {RECOMMENDED.map((item) => {
              const inBag = added.includes(item.name)
              return (
                <View key={item.name} style={styles.card}>
                  <Image
                    source={{ uri: item.image }}
                    resizeMode="cover"
                    accessibilityIgnoresInvertColors
                    style={[
                      styles.image,
                      { backgroundColor: colors.secondary },
                    ]}
                  />
                  <View style={styles.row}>
                    <View style={styles.info}>
                      <Text style={styles.name}>{item.name}</Text>
                      <Text
                        style={[
                          styles.price,
                          { color: colors.mutedForeground },
                        ]}
                      >
                        {item.price}
                      </Text>
                    </View>
                    <Button
                      size="icon-sm"
                      hitSlop={7}
                      accessibilityState={{ selected: inBag }}
                      accessibilityLabel={
                        inBag
                          ? `Remove ${item.name} from bag`
                          : `Add ${item.name} to bag`
                      }
                      onPress={() =>
                        setAdded((current) =>
                          inBag
                            ? current.filter((name) => name !== item.name)
                            : [...current, item.name]
                        )
                      }
                      style={[
                        styles.add,
                        inBag && { backgroundColor: palette.green500 },
                      ]}
                    >
                      <Icon
                        as={inBag ? CheckIcon : PlusIcon}
                        weight="bold"
                        size={15}
                      />
                    </Button>
                  </View>
                </View>
              )
            })}
          </ScrollView>
        </View>
      </ScrollView>
    </Screen>
  )
}

export { EmptyBag }
```

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/Blocks/EmptyBag.swift`: shown above

- `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/Empty.swift`: see [Empty](https://ui.suiss.app/docs/components/empty.md)

- `swift/Components/KeyboardScrollView.swift`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

- `swift/Components/Screen.swift`: see [Screen](https://ui.suiss.app/docs/components/screen.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")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

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:

- `kotlin/blocks/EmptyBag.kt`: shown above

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

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

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

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

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

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

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

## Usage

```tsx
import { EmptyBag } from "@/components/blocks/empty-bag"
```

```tsx
<EmptyBag />
```

Replace `RECOMMENDED` with your products. The component takes no props, so wire the Sign In, Check Out and Continue Shopping buttons to your own navigation.

