# Price Bundle

A dark subscription bundle picker comparing three plans and the services each one includes.

Price Bundle 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/price-bundle

**React**

**Example**

```tsx title="components/blocks/price-bundle.tsx"
"use client"

import {
  CloudIcon,
  FlameIcon,
  GameControllerIcon,
  MusicNoteIcon,
  NewspaperIcon,
  TelevisionIcon,
} from "@phosphor-icons/react"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { cn } from "@/lib/utils"

const services = [
  {
    name: "suiss Music",
    icon: (
      <MusicNoteIcon className="fill-current size-3.25" />
    ),
    tint: "bg-red-500 text-white",
  },
  {
    name: "suiss TV",
    icon: (
      <TelevisionIcon className="size-3.25" />
    ),
    tint: "bg-foreground text-background",
  },
  {
    name: "suiss Arcade",
    icon: (
      <GameControllerIcon className="size-3.25" />
    ),
    tint: "bg-red-500 text-white",
  },
  {
    name: "suiss Cloud+",
    icon: (
      <CloudIcon className="fill-current size-3.25" />
    ),
    tint: "bg-blue-500 text-white",
  },
  {
    name: "Fitness+",
    icon: (
      <FlameIcon className="fill-current size-3.25" />
    ),
    tint: "bg-green-500 text-white",
  },
  {
    name: "News+",
    icon: (
      <NewspaperIcon className="size-3.25" />
    ),
    tint: "bg-pink-500 text-white",
  },
]

const plans = [
  {
    name: "Individual",
    price: 19.95,
    people: "1 person",
    storage: "50 GB",
    included: 4,
    saving: 6,
  },
  {
    name: "Family",
    price: 25.95,
    people: "Up to 6 people",
    storage: "200 GB",
    included: 4,
    saving: 8,
  },
  {
    name: "Premier",
    price: 37.95,
    people: "Up to 6 people",
    storage: "2 TB",
    included: 6,
    saving: 29,
  },
] as const

export function PriceBundle() {
  const [selected, setSelected] = React.useState("Family")
  const plan = plans.find((item) => item.name === selected) ?? plans[1]

  return (
    <section className="dark flex flex-col items-center gap-5.5 bg-background px-5 py-8.5 text-foreground md:px-9">
      <div className="flex flex-col items-center gap-2 text-center">
        <h1 className="text-4xl font-semibold tracking-tight">
          <span className="font-bold">suiss</span> One
        </h1>
        <p className="text-2xl text-balance text-muted-foreground">
          Bundle it all in one subscription.
        </p>
      </div>
      <RadioGroup
        value={selected}
        onValueChange={(value) => setSelected(value as string)}
        aria-label="Plan"
        className="grid w-full max-w-215 grid-cols-1 gap-3 md:grid-cols-3"
      >
        {plans.map((item) => (
          <Label
            key={item.name}
            className="flex cursor-pointer flex-col gap-3.5 rounded-[1.25rem] bg-muted/60 p-5 shadow-[inset_0_0_0_1px_var(--color-input)] transition-[background-color,box-shadow] duration-300 has-data-checked:bg-muted has-data-checked:shadow-[inset_0_0_0_2px_var(--color-primary)] has-focus-visible:ring-3 has-focus-visible:ring-ring/50 font-normal items-stretch"
          >
            <span className="flex items-center gap-2">
              <span className="flex-1 text-2xl font-semibold">{item.name}</span>
              <span className="text-xs text-muted-foreground">
                {item.people}
              </span>
              <RadioGroupItem
                value={item.name}
                aria-label={item.name}
                className="absolute sr-only"
              />
            </span>
            <span>
              <span className="text-2xl font-semibold tabular-nums">
                ${item.price}
              </span>
              <span className="text-sm text-muted-foreground"> /mo</span>
            </span>
            <ul className="flex flex-col gap-2">
              {services.map((service, index) => {
                const included = index < item.included
                return (
                  <li
                    key={service.name}
                    data-included={included ? "" : undefined}
                    className="group/service flex items-center gap-2 text-sm text-muted-foreground line-through data-included:text-foreground data-included:no-underline"
                  >
                    <span
                      className={cn(
                        "flex size-5.5 items-center justify-center rounded-md bg-secondary text-muted-foreground transition-colors duration-300",
                        included && service.tint
                      )}
                    >
                      {service.icon}
                    </span>
                    {service.name}
                    {index === 3 && ` ${item.storage}`}
                    {!included && (
                      <span className="sr-only">(not included)</span>
                    )}
                  </li>
                )
              })}
            </ul>
          </Label>
        ))}
      </RadioGroup>
      <div className="flex flex-wrap items-center justify-center gap-x-4.5 gap-y-3">
        <Button size="lg">Try it free</Button>
        <a href="#compare" className="text-lg text-primary hover:underline">
          Save ${plan.saving} compared to buying separately ›
        </a>
      </div>
    </section>
  )
}
```

A dark plan picker comparing three subscription bundles, each with its audience, monthly price and the services it includes, with excluded services struck through. Cards stack on mobile and sit in three columns on desktop, and the selected one gains a primary outline. A Try it free button and a savings link that follows the selection close the section. Use it for bundle, tier or membership pricing.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/price-bundle
```

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 price-bundle
```

### Manual

1. Install the dependencies and add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper):

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

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Label](https://ui.suiss.app/docs/components/label), [Radio Group](https://ui.suiss.app/docs/components/radio-group).

3. Copy the source into `components/blocks/price-bundle.tsx`:

```tsx title="components/blocks/price-bundle.tsx"
"use client"

import {
  CloudIcon,
  FlameIcon,
  GameControllerIcon,
  MusicNoteIcon,
  NewspaperIcon,
  TelevisionIcon,
} from "@phosphor-icons/react"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { cn } from "@/lib/utils"

const services = [
  {
    name: "suiss Music",
    icon: (
      <MusicNoteIcon className="fill-current size-3.25" />
    ),
    tint: "bg-red-500 text-white",
  },
  {
    name: "suiss TV",
    icon: (
      <TelevisionIcon className="size-3.25" />
    ),
    tint: "bg-foreground text-background",
  },
  {
    name: "suiss Arcade",
    icon: (
      <GameControllerIcon className="size-3.25" />
    ),
    tint: "bg-red-500 text-white",
  },
  {
    name: "suiss Cloud+",
    icon: (
      <CloudIcon className="fill-current size-3.25" />
    ),
    tint: "bg-blue-500 text-white",
  },
  {
    name: "Fitness+",
    icon: (
      <FlameIcon className="fill-current size-3.25" />
    ),
    tint: "bg-green-500 text-white",
  },
  {
    name: "News+",
    icon: (
      <NewspaperIcon className="size-3.25" />
    ),
    tint: "bg-pink-500 text-white",
  },
]

const plans = [
  {
    name: "Individual",
    price: 19.95,
    people: "1 person",
    storage: "50 GB",
    included: 4,
    saving: 6,
  },
  {
    name: "Family",
    price: 25.95,
    people: "Up to 6 people",
    storage: "200 GB",
    included: 4,
    saving: 8,
  },
  {
    name: "Premier",
    price: 37.95,
    people: "Up to 6 people",
    storage: "2 TB",
    included: 6,
    saving: 29,
  },
] as const

export function PriceBundle() {
  const [selected, setSelected] = React.useState("Family")
  const plan = plans.find((item) => item.name === selected) ?? plans[1]

  return (
    <section className="dark flex flex-col items-center gap-5.5 bg-background px-5 py-8.5 text-foreground md:px-9">
      <div className="flex flex-col items-center gap-2 text-center">
        <h1 className="text-4xl font-semibold tracking-tight">
          <span className="font-bold">suiss</span> One
        </h1>
        <p className="text-2xl text-balance text-muted-foreground">
          Bundle it all in one subscription.
        </p>
      </div>
      <RadioGroup
        value={selected}
        onValueChange={(value) => setSelected(value as string)}
        aria-label="Plan"
        className="grid w-full max-w-215 grid-cols-1 gap-3 md:grid-cols-3"
      >
        {plans.map((item) => (
          <Label
            key={item.name}
            className="flex cursor-pointer flex-col gap-3.5 rounded-[1.25rem] bg-muted/60 p-5 shadow-[inset_0_0_0_1px_var(--color-input)] transition-[background-color,box-shadow] duration-300 has-data-checked:bg-muted has-data-checked:shadow-[inset_0_0_0_2px_var(--color-primary)] has-focus-visible:ring-3 has-focus-visible:ring-ring/50 font-normal items-stretch"
          >
            <span className="flex items-center gap-2">
              <span className="flex-1 text-2xl font-semibold">{item.name}</span>
              <span className="text-xs text-muted-foreground">
                {item.people}
              </span>
              <RadioGroupItem
                value={item.name}
                aria-label={item.name}
                className="absolute sr-only"
              />
            </span>
            <span>
              <span className="text-2xl font-semibold tabular-nums">
                ${item.price}
              </span>
              <span className="text-sm text-muted-foreground"> /mo</span>
            </span>
            <ul className="flex flex-col gap-2">
              {services.map((service, index) => {
                const included = index < item.included
                return (
                  <li
                    key={service.name}
                    data-included={included ? "" : undefined}
                    className="group/service flex items-center gap-2 text-sm text-muted-foreground line-through data-included:text-foreground data-included:no-underline"
                  >
                    <span
                      className={cn(
                        "flex size-5.5 items-center justify-center rounded-md bg-secondary text-muted-foreground transition-colors duration-300",
                        included && service.tint
                      )}
                    >
                      {service.icon}
                    </span>
                    {service.name}
                    {index === 3 && ` ${item.storage}`}
                    {!included && (
                      <span className="sr-only">(not included)</span>
                    )}
                  </li>
                )
              })}
            </ul>
          </Label>
        ))}
      </RadioGroup>
      <div className="flex flex-wrap items-center justify-center gap-x-4.5 gap-y-3">
        <Button size="lg">Try it free</Button>
        <a href="#compare" className="text-lg text-primary hover:underline">
          Save ${plan.saving} compared to buying separately ›
        </a>
      </div>
    </section>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { PriceBundle } from "@/components/blocks/price-bundle"
```

```tsx
<PriceBundle />
```

Edit `services` and `plans`. Each plan includes the first services in order, up to its `included` count, and the fourth service shows the plan's storage, so order `services` from most basic to most premium. Give Try it free a handler and point `#compare` at your comparison page.

## Notes

Excluded services include hidden not-included text, so the strike-through is not the only cue.

**React Native, SwiftUI, Jetpack Compose**

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

```tsx title="uniwind/components/blocks/price-bundle.tsx"
import { cn } from "cn"
import {
  CloudIcon,
  FlameIcon,
  GameControllerIcon,
  MusicNoteIcon,
  NewspaperIcon,
  type Icon as PhosphorIcon,
  TelevisionIcon,
} from "phosphor-react-native"
import * as React from "react"
import { Pressable, ScrollView, View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

const SERVICES: {
  name: string
  icon: PhosphorIcon
  tint: string
  iconClass: string
}[] = [
  {
    name: "suiss Music",
    icon: MusicNoteIcon,
    tint: "bg-red-500",
    iconClass: "text-primary-foreground",
  },
  {
    name: "suiss TV",
    icon: TelevisionIcon,
    tint: "bg-foreground",
    iconClass: "text-background",
  },
  {
    name: "suiss Arcade",
    icon: GameControllerIcon,
    tint: "bg-red-500",
    iconClass: "text-primary-foreground",
  },
  {
    name: "suiss Cloud+",
    icon: CloudIcon,
    tint: "bg-blue-500",
    iconClass: "text-primary-foreground",
  },
  {
    name: "Fitness+",
    icon: FlameIcon,
    tint: "bg-green-500",
    iconClass: "text-primary-foreground",
  },
  {
    name: "News+",
    icon: NewspaperIcon,
    tint: "bg-pink-500",
    iconClass: "text-primary-foreground",
  },
]

const PLANS = [
  {
    name: "Individual",
    price: 19.95,
    people: "1 person",
    storage: "50 GB",
    included: 4,
    saving: 6,
  },
  {
    name: "Family",
    price: 25.95,
    people: "Up to 6 people",
    storage: "200 GB",
    included: 4,
    saving: 8,
  },
  {
    name: "Premier",
    price: 37.95,
    people: "Up to 6 people",
    storage: "2 TB",
    included: 6,
    saving: 29,
  },
]

const money = (value: number, digits = 2) => {
  try {
    return new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: "USD",
      minimumFractionDigits: digits,
      maximumFractionDigits: digits,
    }).format(value)
  } catch {
    const [whole, fraction] = value.toFixed(digits).split(".")
    const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
    return `$${grouped}${fraction ? `.${fraction}` : ""}`
  }
}

function PriceBundle({
  onSelectPlan,
  onCompare,
}: {
  onSelectPlan?: (plan: string) => void
  onCompare?: (plan: string) => void
}) {
  const [selected, setSelected] = React.useState("Family")
  const plan = PLANS.find((item) => item.name === selected) ?? PLANS[1]

  return (
    <Screen edges={["top", "right", "left"]}>
      <ScrollView contentContainerClassName="gap-5.5 px-5 pt-8.5 pb-6">
        <AnimatedView
          entering={FadeInDown.duration(600).reduceMotion(ReduceMotion.System)}
          className="items-center gap-2"
        >
          <Text role="heading" className="text-center text-3xl font-semibold">
            <Text className="text-3xl font-bold">suiss</Text> One
          </Text>
          <Text className="text-center text-2xl text-muted-foreground">
            Bundle it all in one subscription.
          </Text>
        </AnimatedView>
        <View
          accessibilityRole="radiogroup"
          accessibilityLabel="Plan"
          className="gap-3"
        >
          {PLANS.map((item) => {
            const checked = item.name === selected
            return (
              <Pressable
                key={item.name}
                role="radio"
                accessibilityState={{ checked }}
                accessibilityLabel={`${item.name}, ${money(item.price)} per month, ${item.people}`}
                onPress={() => setSelected(item.name)}
                className={cn(
                  "gap-3.5 rounded-[1.25rem] border-2 p-5",
                  checked
                    ? "border-primary bg-muted"
                    : "border-border bg-muted/60 active:opacity-80"
                )}
              >
                <View className="flex-row items-center gap-2">
                  <Text className="flex-1 text-2xl font-semibold">
                    {item.name}
                  </Text>
                  <Text className="text-xs text-muted-foreground">
                    {item.people}
                  </Text>
                </View>
                <Text>
                  <Text className="text-2xl font-semibold tabular-nums">
                    {money(item.price)}
                  </Text>
                  <Text className="text-sm text-muted-foreground"> /mo</Text>
                </Text>
                <View className="gap-2">
                  {SERVICES.map((service, index) => {
                    const included = index < item.included
                    const label = `${service.name}${index === 3 ? ` ${item.storage}` : ""}`
                    return (
                      <View
                        key={service.name}
                        accessible
                        accessibilityLabel={
                          included ? label : `${label} (not included)`
                        }
                        className="flex-row items-center gap-2"
                      >
                        <View
                          className={cn(
                            "size-5.5 items-center justify-center rounded-md",
                            included ? service.tint : "bg-secondary"
                          )}
                        >
                          <Icon
                            as={service.icon}
                            weight="fill"
                            className={cn(
                              "size-3.25",
                              included
                                ? service.iconClass
                                : "text-muted-foreground"
                            )}
                          />
                        </View>
                        <Text
                          className={cn(
                            "text-sm",
                            included
                              ? "text-foreground"
                              : "text-muted-foreground line-through"
                          )}
                        >
                          {label}
                        </Text>
                      </View>
                    )
                  })}
                </View>
              </Pressable>
            )
          })}
        </View>
      </ScrollView>
      <BottomBar separator>
        <BottomBarContent className="items-center gap-3">
          <Button size="lg" block onPress={() => onSelectPlan?.(plan.name)}>
            <Text>Try it free</Text>
          </Button>
          <Pressable
            role="link"
            hitSlop={8}
            onPress={() => onCompare?.(plan.name)}
            className="active:opacity-60"
          >
            <Text
              accessibilityLiveRegion="polite"
              className="text-center text-base text-primary"
            >
              Save {money(plan.saving, 0)} compared to buying separately ›
            </Text>
          </Pressable>
        </BottomBarContent>
      </BottomBar>
    </Screen>
  )
}

export { PriceBundle }
```

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

public struct PriceBundle: View {
  struct Service: Identifiable {
    let name: String
    let icon: Image
    let tint: Color
    let foreground: Color

    var id: String { name }
  }

  struct Plan: Identifiable {
    let name: String
    let price: Double
    let people: String
    let storage: String
    let included: Int
    let saving: Int

    var id: String { name }
  }

  static var services: [Service] {
    [
      Service(name: "suiss Music", icon: Ph.musicNote.fill, tint: Palette.red500, foreground: EsuissColor.primaryForeground),
      Service(name: "suiss TV", icon: Ph.television.fill, tint: EsuissColor.foreground, foreground: EsuissColor.background),
      Service(name: "suiss Arcade", icon: Ph.gameController.fill, tint: Palette.red500, foreground: EsuissColor.primaryForeground),
      Service(name: "suiss Cloud+", icon: Ph.cloud.fill, tint: Palette.blue500, foreground: EsuissColor.primaryForeground),
      Service(name: "Fitness+", icon: Ph.flame.fill, tint: Palette.green500, foreground: EsuissColor.primaryForeground),
      Service(name: "News+", icon: Ph.newspaper.fill, tint: Palette.pink500, foreground: EsuissColor.primaryForeground),
    ]
  }

  static var plans: [Plan] {
    [
      Plan(name: "Individual", price: 19.95, people: "1 person", storage: "50 GB", included: 4, saving: 6),
      Plan(name: "Family", price: 25.95, people: "Up to 6 people", storage: "200 GB", included: 4, saving: 8),
      Plan(name: "Premier", price: 37.95, people: "Up to 6 people", storage: "2 TB", included: 6, saving: 29),
    ]
  }

  private var onSelectPlan: ((String) -> Void)?
  private var onCompare: ((String) -> Void)?
  @State private var selected: String
  @Environment(\.locale) private var locale
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(onSelectPlan: ((_ plan: String) -> Void)? = nil, onCompare: ((_ plan: String) -> Void)? = nil) {
    self.init(selected: "Family")
    self.onSelectPlan = onSelectPlan
    self.onCompare = onCompare
  }

  init(selected: String) {
    _selected = State(initialValue: selected)
  }

  private var plan: Plan {
    Self.plans.first { $0.name == selected } ?? Self.plans[1]
  }

  private func money(_ value: Double, digits: Int = 2) -> String {
    value.formatted(.currency(code: "USD").precision(.fractionLength(digits)).locale(locale))
  }

  public var body: some View {
    Screen {
      KeyboardScrollView {
        VStack(spacing: 22) {
          VStack(spacing: 8) {
            Text("\(Text(verbatim: "suiss").fontWeight(.bold)) One")
              .scaledTextStyle(.xl3, weight: .semibold)
              .accessibilityAddTraits(.isHeader)
            Text("Bundle it all in one subscription.")
              .scaledTextStyle(.xl)
              .foregroundStyle(EsuissColor.mutedForeground)
          }
          .multilineTextAlignment(.center)
          .fixedSize(horizontal: false, vertical: true)
          VStack(spacing: 12) {
            ForEach(Self.plans) { item in
              PriceBundleCard(plan: item, selected: item.name == selected, price: money(item.price)) {
                selected = item.name
              }
            }
          }
          .accessibilityElement(children: .contain)
          .accessibilityLabel(Text("Plan"))
        }
        .padding(.horizontal, 20)
        .padding(.top, 34)
        .padding(.bottom, 24)
      }
      BottomBar {
        BottomBarContent {
          Button("Try it free") {
            onSelectPlan?(selected)
          }
          .buttonStyle(.variant(.default, size: .lg, block: true))
          Button {
            onCompare?(selected)
          } label: {
            Text("Save \(money(Double(plan.saving), digits: 0)) compared to buying separately ›")
              .contentTransition(.numericText())
          }
          .buttonStyle(.variant(.link, size: .sm))
        }
      }
    }
    .animation(reduceMotion ? nil : .easeOut(duration: 0.3), value: selected)
  }
}

private struct PriceBundleCard: View {
  let plan: PriceBundle.Plan
  let selected: Bool
  let price: String
  let action: () -> Void

  var body: some View {
    let shape = RoundedRectangle(cornerRadius: 20, style: .continuous)
    Button(action: action) {
      VStack(alignment: .leading, spacing: 14) {
        HStack(spacing: 8) {
          Text(LocalizedStringKey(plan.name))
            .scaledFont(.xl, weight: .semibold)
            .frame(maxWidth: .infinity, alignment: .leading)
          Text(LocalizedStringKey(plan.people))
            .scaledFont(.xs)
            .foregroundStyle(EsuissColor.mutedForeground)
        }
        HStack(alignment: .firstTextBaseline, spacing: 0) {
          Text(verbatim: price)
            .scaledFont(.xl, weight: .semibold)
            .monospacedDigit()
          Text(verbatim: " ")
          Text("/mo")
            .scaledFont(.sm)
            .foregroundStyle(EsuissColor.mutedForeground)
        }
        VStack(alignment: .leading, spacing: 8) {
          ForEach(Array(PriceBundle.services.enumerated()), id: \.element.id) { index, service in
            let included = index < plan.included
            HStack(spacing: 8) {
              service.icon
                .frame(width: 13, height: 13)
                .foregroundStyle(included ? service.foreground : EsuissColor.mutedForeground)
                .frame(width: 22, height: 22)
                .background(included ? service.tint : EsuissColor.secondary, in: RoundedRectangle(cornerRadius: Radius.md, style: .continuous))
              Text(verbatim: index == 3 ? "\(service.name) \(plan.storage)" : service.name)
                .strikethrough(!included)
                .foregroundStyle(included ? EsuissColor.foreground : EsuissColor.mutedForeground)
            }
            .scaledFont(.sm)
            .accessibilityElement(children: .combine)
            .accessibilityValue(included ? Text(verbatim: "") : Text("Not included"))
          }
        }
      }
      .padding(20)
      .foregroundStyle(EsuissColor.foreground)
      .frame(maxWidth: .infinity, alignment: .leading)
      .contentShape(shape)
    }
    .buttonStyle(PriceBundleCardStyle(selected: selected))
    .accessibilityAddTraits(selected ? .isSelected : [])
    .hapticFeedback(.selection, trigger: selected) { _, now in now }
  }
}

private struct PriceBundleCardStyle: ButtonStyle {
  let selected: Bool

  func makeBody(configuration: Configuration) -> some View {
    let shape = RoundedRectangle(cornerRadius: 20, style: .continuous)
    configuration.label
      .background((selected || configuration.isPressed) ? EsuissColor.muted : EsuissColor.muted.opacity(0.6), in: shape)
      .overlay {
        shape.strokeBorder(selected ? EsuissColor.primary : EsuissColor.input, lineWidth: selected ? 2 : 1)
      }
  }
}
```

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

import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
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.mutableStateOf
import androidx.compose.runtime.remember
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.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
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.semantics
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.fill.Cloud
import com.adamglin.phosphoricons.fill.Flame
import com.adamglin.phosphoricons.fill.GameController
import com.adamglin.phosphoricons.fill.MusicNote
import com.adamglin.phosphoricons.fill.Newspaper
import com.adamglin.phosphoricons.fill.Television
import com.esuiss.ui.components.BottomBar
import com.esuiss.ui.components.BottomBarContent
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.rememberHaptics
import com.esuiss.ui.theme.Colors
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Typography
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale

private class BundleService(
    val name: String,
    val icon: ImageVector,
    val background: (Colors) -> Color,
    val foreground: (Colors) -> Color,
)

private class BundlePlan(
    val name: String,
    val price: Double,
    val people: String,
    val storage: String,
    val included: Int,
    val saving: Int,
)

private val BundleServices = listOf(
    BundleService("suiss Music", PhosphorIcons.Fill.MusicNote, { Palette.red500 }, { Palette.white }),
    BundleService("suiss TV", PhosphorIcons.Fill.Television, { it.foreground }, { it.background }),
    BundleService("suiss Arcade", PhosphorIcons.Fill.GameController, { Palette.red500 }, { Palette.white }),
    BundleService("suiss Cloud+", PhosphorIcons.Fill.Cloud, { Palette.blue500 }, { Palette.white }),
    BundleService("Fitness+", PhosphorIcons.Fill.Flame, { Palette.green500 }, { Palette.white }),
    BundleService("News+", PhosphorIcons.Fill.Newspaper, { Palette.pink500 }, { Palette.white }),
)

private val BundlePlans = listOf(
    BundlePlan("Individual", 19.95, "1 person", "50 GB", 4, 6),
    BundlePlan("Family", 25.95, "Up to 6 people", "200 GB", 4, 8),
    BundlePlan("Premier", 37.95, "Up to 6 people", "2 TB", 6, 29),
)

private fun bundleMoney(value: Double): String =
    NumberFormat.getCurrencyInstance(Locale.US).apply { currency = Currency.getInstance("USD") }.format(value)

@Composable
fun PriceBundle(
    modifier: Modifier = Modifier,
    onSelectPlan: (plan: String) -> Unit = {},
    onCompare: (plan: String) -> Unit = {},
) {
    var selected by rememberSaveable { mutableStateOf("Family") }
    val plan = BundlePlans.firstOrNull { it.name == selected } ?: BundlePlans[1]

    val colors = EsuissTheme.colors
    Screen(modifier) {
        Column(Modifier.fillMaxSize()) {
            Column(
                Modifier
                    .weight(1f)
                    .verticalScroll(rememberScrollState())
                    .padding(horizontal = 20.dp, vertical = 34.dp),
                verticalArrangement = Arrangement.spacedBy(22.dp),
            ) {
                Column(
                    Modifier.fillMaxWidth(),
                    horizontalAlignment = Alignment.CenterHorizontally,
                    verticalArrangement = Arrangement.spacedBy(8.dp),
                ) {
                    BasicText(
                        buildAnnotatedString {
                            withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append("suiss") }
                            append(" One")
                        },
                        modifier = Modifier.semantics { heading() },
                        style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                    )
                    BasicText(
                        "Bundle it all in one subscription.",
                        style = Typography.xl.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                    )
                }
                Column(
                    Modifier
                        .selectableGroup()
                        .semantics { contentDescription = "Plan" },
                    verticalArrangement = Arrangement.spacedBy(12.dp),
                ) {
                    BundlePlans.forEach { item ->
                        BundleCard(item, selected = item.name == selected) { selected = item.name }
                    }
                }
            }
            BottomBar(separator = true) {
                BottomBarContent {
                    Button("Try it free", { onSelectPlan(plan.name) }, size = ButtonSize.Lg, block = true)
                    BundleLink("Save \$${plan.saving} compared to buying separately ›") { onCompare(plan.name) }
                }
            }
        }
    }
}

@Composable
private fun BundleCard(plan: BundlePlan, selected: Boolean, onSelect: () -> Unit) {
    val colors = EsuissTheme.colors
    val haptics = rememberHaptics()
    val shape = RoundedCornerShape(20.dp)
    val fill by animateColorAsState(
        if (selected) colors.muted else colors.muted.copy(alpha = 0.6f),
        tween(300),
        label = "fill",
    )
    val border by animateColorAsState(if (selected) colors.primary else colors.input, tween(300), label = "border")
    Column(
        Modifier
            .fillMaxWidth()
            .background(fill, shape)
            .border(if (selected) 2.dp else 1.dp, border, shape)
            .selectable(
                selected = selected,
                interactionSource = remember { MutableInteractionSource() },
                indication = null,
                role = Role.RadioButton,
                onClick = {
                    if (!selected) haptics.selection()
                    onSelect()
                },
            )
            .padding(20.dp),
        verticalArrangement = Arrangement.spacedBy(14.dp),
    ) {
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
            BasicText(
                plan.name,
                modifier = Modifier.weight(1f),
                style = Typography.xl2.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
            )
            BasicText(plan.people, style = Typography.xs.copy(color = colors.mutedForeground))
        }
        BasicText(
            buildAnnotatedString {
                withStyle(SpanStyle(fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum")) { append(bundleMoney(plan.price)) }
                withStyle(Typography.sm.toSpanStyle().copy(color = colors.mutedForeground)) { append(" /mo") }
            },
            style = Typography.xl2.copy(color = colors.foreground),
        )
        Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
            BundleServices.forEachIndexed { index, service ->
                val included = index < plan.included
                val text = if (index == 3) "${service.name} ${plan.storage}" else service.name
                Row(
                    modifier = Modifier.semantics(mergeDescendants = true) {
                        if (!included) contentDescription = "$text (not included)"
                    },
                    horizontalArrangement = Arrangement.spacedBy(8.dp),
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    Box(
                        Modifier
                            .size(22.dp)
                            .background(if (included) service.background(colors) else colors.secondary, RoundedCornerShape(6.dp))
                            .clearAndSetSemantics {},
                        contentAlignment = Alignment.Center,
                    ) {
                        Icon(
                            service.icon,
                            contentDescription = null,
                            size = 13.dp,
                            tint = if (included) service.foreground(colors) else colors.mutedForeground,
                        )
                    }
                    BasicText(
                        text,
                        style = Typography.sm.copy(
                            color = if (included) colors.foreground else colors.mutedForeground,
                            textDecoration = if (included) null else TextDecoration.LineThrough,
                        ),
                    )
                }
            }
        }
    }
}

@Composable
private fun BundleLink(text: String, onClick: () -> Unit) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    BasicText(
        text,
        modifier = Modifier
            .graphicsLayer { alpha = if (pressed) 0.8f else 1f }
            .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onClick)
            .padding(vertical = 6.dp),
        style = Typography.base.copy(color = EsuissTheme.colors.primary, textAlign = TextAlign.Center),
    )
}
```

A pricing screen with selectable plan cards that act as radio buttons. Each card shows the price, who it covers, and the bundled services with icons, striking through any that the plan leaves out. A `BottomBar` holds a Try it free `Button` and a link that shows how much the selected plan saves.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/price-bundle
```

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 price-bundle
```

**SwiftUI**

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

**Jetpack Compose**

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

The CLI also adds what it builds on: [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Button](https://ui.suiss.app/docs/components/button), [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: [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Button](https://ui.suiss.app/docs/components/button), [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/price-bundle.tsx`:

- `uniwind/components/blocks/price-bundle.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: [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Button](https://ui.suiss.app/docs/components/button), [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/price-bundle.tsx`:

```tsx title="nativewind/components/blocks/price-bundle.tsx"
import { cn } from "cn"
import {
  CloudIcon,
  FlameIcon,
  GameControllerIcon,
  MusicNoteIcon,
  NewspaperIcon,
  type Icon as PhosphorIcon,
  TelevisionIcon,
} from "phosphor-react-native"
import * as React from "react"
import { Pressable, ScrollView, View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

const SERVICES: {
  name: string
  icon: PhosphorIcon
  tint: string
  iconClass: string
}[] = [
  {
    name: "suiss Music",
    icon: MusicNoteIcon,
    tint: "bg-red-500",
    iconClass: "text-primary-foreground",
  },
  {
    name: "suiss TV",
    icon: TelevisionIcon,
    tint: "bg-foreground",
    iconClass: "text-background",
  },
  {
    name: "suiss Arcade",
    icon: GameControllerIcon,
    tint: "bg-red-500",
    iconClass: "text-primary-foreground",
  },
  {
    name: "suiss Cloud+",
    icon: CloudIcon,
    tint: "bg-blue-500",
    iconClass: "text-primary-foreground",
  },
  {
    name: "Fitness+",
    icon: FlameIcon,
    tint: "bg-green-500",
    iconClass: "text-primary-foreground",
  },
  {
    name: "News+",
    icon: NewspaperIcon,
    tint: "bg-pink-500",
    iconClass: "text-primary-foreground",
  },
]

const PLANS = [
  {
    name: "Individual",
    price: 19.95,
    people: "1 person",
    storage: "50 GB",
    included: 4,
    saving: 6,
  },
  {
    name: "Family",
    price: 25.95,
    people: "Up to 6 people",
    storage: "200 GB",
    included: 4,
    saving: 8,
  },
  {
    name: "Premier",
    price: 37.95,
    people: "Up to 6 people",
    storage: "2 TB",
    included: 6,
    saving: 29,
  },
]

const money = (value: number, digits = 2) => {
  try {
    return new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: "USD",
      minimumFractionDigits: digits,
      maximumFractionDigits: digits,
    }).format(value)
  } catch {
    const [whole, fraction] = value.toFixed(digits).split(".")
    const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
    return `$${grouped}${fraction ? `.${fraction}` : ""}`
  }
}

function PriceBundle({
  onSelectPlan,
  onCompare,
}: {
  onSelectPlan?: (plan: string) => void
  onCompare?: (plan: string) => void
}) {
  const [selected, setSelected] = React.useState("Family")
  const plan = PLANS.find((item) => item.name === selected) ?? PLANS[1]

  return (
    <Screen edges={["top", "right", "left"]}>
      <ScrollView contentContainerClassName="gap-[1.375rem] px-5 pt-[2.125rem] pb-6">
        <AnimatedView
          entering={FadeInDown.duration(600).reduceMotion(ReduceMotion.System)}
          className="items-center gap-2"
        >
          <Text role="heading" className="text-center text-3xl font-semibold">
            <Text className="text-3xl font-bold">suiss</Text> One
          </Text>
          <Text className="text-center text-2xl text-muted-foreground">
            Bundle it all in one subscription.
          </Text>
        </AnimatedView>
        <View
          accessibilityRole="radiogroup"
          accessibilityLabel="Plan"
          className="gap-3"
        >
          {PLANS.map((item) => {
            const checked = item.name === selected
            return (
              <Pressable
                key={item.name}
                role="radio"
                accessibilityState={{ checked }}
                accessibilityLabel={`${item.name}, ${money(item.price)} per month, ${item.people}`}
                onPress={() => setSelected(item.name)}
                className={cn(
                  "gap-3.5 rounded-[1.25rem] border-2 p-5",
                  checked
                    ? "border-primary bg-muted"
                    : "border-border bg-muted-60 active:opacity-80"
                )}
              >
                <View className="flex-row items-center gap-2">
                  <Text className="flex-1 text-2xl font-semibold">
                    {item.name}
                  </Text>
                  <Text className="text-xs text-muted-foreground">
                    {item.people}
                  </Text>
                </View>
                <Text>
                  <Text className="text-2xl font-semibold tabular-nums">
                    {money(item.price)}
                  </Text>
                  <Text className="text-sm text-muted-foreground"> /mo</Text>
                </Text>
                <View className="gap-2">
                  {SERVICES.map((service, index) => {
                    const included = index < item.included
                    const label = `${service.name}${index === 3 ? ` ${item.storage}` : ""}`
                    return (
                      <View
                        key={service.name}
                        accessible
                        accessibilityLabel={
                          included ? label : `${label} (not included)`
                        }
                        className="flex-row items-center gap-2"
                      >
                        <View
                          className={cn(
                            "size-[1.375rem] items-center justify-center rounded-md",
                            included ? service.tint : "bg-secondary"
                          )}
                        >
                          <Icon
                            as={service.icon}
                            weight="fill"
                            className={cn(
                              "size-[0.8125rem]",
                              included
                                ? service.iconClass
                                : "text-muted-foreground"
                            )}
                          />
                        </View>
                        <Text
                          className={cn(
                            "text-sm",
                            included
                              ? "text-foreground"
                              : "text-muted-foreground line-through"
                          )}
                        >
                          {label}
                        </Text>
                      </View>
                    )
                  })}
                </View>
              </Pressable>
            )
          })}
        </View>
      </ScrollView>
      <BottomBar separator>
        <BottomBarContent className="items-center gap-3">
          <Button size="lg" block onPress={() => onSelectPlan?.(plan.name)}>
            <Text>Try it free</Text>
          </Button>
          <Pressable
            role="link"
            hitSlop={8}
            onPress={() => onCompare?.(plan.name)}
            className="active:opacity-60"
          >
            <Text
              accessibilityLiveRegion="polite"
              className="text-center text-base text-primary"
            >
              Save {money(plan.saving, 0)} compared to buying separately ›
            </Text>
          </Pressable>
        </BottomBarContent>
      </BottomBar>
    </Screen>
  )
}

export { PriceBundle }
```

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: [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Button](https://ui.suiss.app/docs/components/button), [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/price-bundle.tsx`:

```tsx title="stylesheet/components/blocks/price-bundle.tsx"
import {
  CloudIcon,
  FlameIcon,
  GameControllerIcon,
  MusicNoteIcon,
  NewspaperIcon,
  type Icon as PhosphorIcon,
  TelevisionIcon,
} from "phosphor-react-native"
import * as React from "react"
import { Pressable, ScrollView, StyleSheet, View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import {
  fontSize,
  palette,
  type ThemeColors,
  useTheme,
} from "@/components/ui/theme"

type Tone = keyof ThemeColors | keyof typeof palette

function tone(colors: ThemeColors, key: Tone) {
  return { ...colors, ...palette }[key]
}

const SERVICES: {
  name: string
  icon: PhosphorIcon
  tint: Tone
  iconColor: Tone
}[] = [
  {
    name: "suiss Music",
    icon: MusicNoteIcon,
    tint: "red500",
    iconColor: "primaryForeground",
  },
  {
    name: "suiss TV",
    icon: TelevisionIcon,
    tint: "foreground",
    iconColor: "background",
  },
  {
    name: "suiss Arcade",
    icon: GameControllerIcon,
    tint: "red500",
    iconColor: "primaryForeground",
  },
  {
    name: "suiss Cloud+",
    icon: CloudIcon,
    tint: "blue500",
    iconColor: "primaryForeground",
  },
  {
    name: "Fitness+",
    icon: FlameIcon,
    tint: "green500",
    iconColor: "primaryForeground",
  },
  {
    name: "News+",
    icon: NewspaperIcon,
    tint: "pink500",
    iconColor: "primaryForeground",
  },
]

const PLANS = [
  {
    name: "Individual",
    price: 19.95,
    people: "1 person",
    storage: "50 GB",
    included: 4,
    saving: 6,
  },
  {
    name: "Family",
    price: 25.95,
    people: "Up to 6 people",
    storage: "200 GB",
    included: 4,
    saving: 8,
  },
  {
    name: "Premier",
    price: 37.95,
    people: "Up to 6 people",
    storage: "2 TB",
    included: 6,
    saving: 29,
  },
]

const money = (value: number, digits = 2) => {
  try {
    return new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: "USD",
      minimumFractionDigits: digits,
      maximumFractionDigits: digits,
    }).format(value)
  } catch {
    const [whole, fraction] = value.toFixed(digits).split(".")
    const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
    return `$${grouped}${fraction ? `.${fraction}` : ""}`
  }
}

const styles = StyleSheet.create({
  content: {
    gap: 22,
    paddingHorizontal: 20,
    paddingTop: 34,
    paddingBottom: 24,
  },
  intro: { alignItems: "center", gap: 8 },
  title: { ...fontSize["3xl"], fontWeight: "600", textAlign: "center" },
  brand: { ...fontSize["3xl"], fontWeight: "700" },
  subtitle: { ...fontSize["2xl"], textAlign: "center" },
  plans: { gap: 12 },
  card: { gap: 14, borderRadius: 20, borderWidth: 2, padding: 20 },
  cardHead: { flexDirection: "row", alignItems: "center", gap: 8 },
  name: { ...fontSize["2xl"], flex: 1, fontWeight: "600" },
  price: {
    ...fontSize["2xl"],
    fontWeight: "600",
    fontVariant: ["tabular-nums"],
  },
  services: { gap: 8 },
  service: { flexDirection: "row", alignItems: "center", gap: 8 },
  serviceIcon: {
    width: 22,
    height: 22,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: 6,
  },
  bar: { alignItems: "center", gap: 12 },
})

function PriceBundle({
  onSelectPlan,
  onCompare,
}: {
  onSelectPlan?: (plan: string) => void
  onCompare?: (plan: string) => void
}) {
  const { colors } = useTheme()
  const [selected, setSelected] = React.useState("Family")
  const plan = PLANS.find((item) => item.name === selected) ?? PLANS[1]

  return (
    <Screen edges={["top", "right", "left"]}>
      <ScrollView contentContainerStyle={styles.content}>
        <AnimatedView
          entering={FadeInDown.duration(600).reduceMotion(ReduceMotion.System)}
          style={styles.intro}
        >
          <Text role="heading" style={styles.title}>
            <Text style={styles.brand}>suiss</Text> One
          </Text>
          <Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
            Bundle it all in one subscription.
          </Text>
        </AnimatedView>
        <View
          accessibilityRole="radiogroup"
          accessibilityLabel="Plan"
          style={styles.plans}
        >
          {PLANS.map((item) => {
            const checked = item.name === selected
            return (
              <Pressable
                key={item.name}
                role="radio"
                accessibilityState={{ checked }}
                accessibilityLabel={`${item.name}, ${money(item.price)} per month, ${item.people}`}
                onPress={() => setSelected(item.name)}
                style={({ pressed }) => [
                  styles.card,
                  {
                    borderColor: checked ? colors.primary : colors.border,
                    backgroundColor: checked
                      ? colors.muted
                      : `${colors.muted}99`,
                  },
                  pressed && { opacity: 0.8 },
                ]}
              >
                <View style={styles.cardHead}>
                  <Text style={styles.name}>{item.name}</Text>
                  <Text
                    style={[fontSize.xs, { color: colors.mutedForeground }]}
                  >
                    {item.people}
                  </Text>
                </View>
                <Text>
                  <Text style={styles.price}>{money(item.price)}</Text>
                  <Text
                    style={[fontSize.sm, { color: colors.mutedForeground }]}
                  >
                    {" "}
                    /mo
                  </Text>
                </Text>
                <View style={styles.services}>
                  {SERVICES.map((service, index) => {
                    const included = index < item.included
                    const label = `${service.name}${index === 3 ? ` ${item.storage}` : ""}`
                    return (
                      <View
                        key={service.name}
                        accessible
                        accessibilityLabel={
                          included ? label : `${label} (not included)`
                        }
                        style={styles.service}
                      >
                        <View
                          style={[
                            styles.serviceIcon,
                            {
                              backgroundColor: included
                                ? tone(colors, service.tint)
                                : colors.secondary,
                            },
                          ]}
                        >
                          <Icon
                            as={service.icon}
                            weight="fill"
                            size={13}
                            color={
                              included
                                ? tone(colors, service.iconColor)
                                : colors.mutedForeground
                            }
                          />
                        </View>
                        <Text
                          style={[
                            fontSize.sm,
                            included
                              ? { color: colors.foreground }
                              : {
                                  color: colors.mutedForeground,
                                  textDecorationLine: "line-through",
                                },
                          ]}
                        >
                          {label}
                        </Text>
                      </View>
                    )
                  })}
                </View>
              </Pressable>
            )
          })}
        </View>
      </ScrollView>
      <BottomBar separator>
        <BottomBarContent style={styles.bar}>
          <Button size="lg" block onPress={() => onSelectPlan?.(plan.name)}>
            <Text>Try it free</Text>
          </Button>
          <Pressable
            role="link"
            hitSlop={8}
            onPress={() => onCompare?.(plan.name)}
            style={({ pressed }) => pressed && { opacity: 0.6 }}
          >
            <Text
              accessibilityLiveRegion="polite"
              style={[
                fontSize.base,
                { color: colors.primary, textAlign: "center" },
              ]}
            >
              Save {money(plan.saving, 0)} compared to buying separately ›
            </Text>
          </Pressable>
        </BottomBarContent>
      </BottomBar>
    </Screen>
  )
}

export { PriceBundle }
```

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

- `swift/Components/BottomBar.swift`: see [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar.md)

- `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/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.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/PriceBundle.kt`: shown above

- `kotlin/components/BottomBar.kt`: see [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar.md)

- `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/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.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 { PriceBundle } from "@/components/blocks/price-bundle"
```

```tsx
<PriceBundle />
```

Replace `PLANS` and `SERVICES` with your offer. Wire `onSelectPlan` and `onCompare` (both receive the plan name) to your purchase flow.

