# Price Storage

A cloud storage plan picker with a monthly or yearly toggle, four plan cards, and a switch action.

Price Storage 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-storage

**React**

**Example**

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

import { CheckIcon, CloudIcon } 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 { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

const plans = [
  {
    size: "50 GB",
    price: 0.99,
    features: ["Space for photos and files", "Private Relay", "Hide My Email"],
  },
  {
    size: "200 GB",
    price: 2.99,
    features: [
      "Share with your family",
      "Custom email domain",
      "Secure Video for 1 camera",
    ],
    popular: true,
  },
  {
    size: "2 TB",
    price: 9.99,
    features: ["Everything in 200 GB", "Unlimited Secure Video cameras"],
  },
  {
    size: "6 TB",
    price: 29.99,
    features: ["Everything in 2 TB", "Room for large archives"],
  },
]

const money = (value: number) =>
  value.toLocaleString("en-US", { style: "currency", currency: "USD" })

export function PriceStorage() {
  const [period, setPeriod] = React.useState("monthly")
  const [selected, setSelected] = React.useState("200 GB")
  const [updated, setUpdated] = React.useState(false)
  const yearly = period === "yearly"

  return (
    <section className="flex flex-col items-center gap-6 px-5 py-9 md:px-9">
      <div className="flex flex-col items-center gap-2 text-center">
        <CloudIcon className="size-10 text-blue-500" />
        <h1 className="text-4xl font-semibold tracking-tight text-balance md:text-5xl">
          More space with suiss Cloud+.
        </h1>
        <ToggleGroup
          spacing={0}
          value={[period]}
          onValueChange={(value: string[]) => {
            if (value[0]) setPeriod(value[0])
          }}
          aria-label="Billing period"
          className="mt-2"
        >
          <ToggleGroupItem value="monthly" size="sm" className="px-12">
            Monthly
          </ToggleGroupItem>
          <ToggleGroupItem value="yearly" size="sm">
            Yearly · 2 months free
          </ToggleGroupItem>
        </ToggleGroup>
      </div>
      <RadioGroup
        value={selected}
        onValueChange={(value) => {
          setSelected(value as string)
          setUpdated(false)
        }}
        aria-label="Storage plan"
        className="grid w-full max-w-240 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"
      >
        {plans.map((plan) => (
          <Label
            key={plan.size}
            className="relative flex cursor-pointer flex-col gap-3 rounded-[1.125rem] bg-muted p-4.5 transition-[translate,box-shadow] duration-350 ease-[cubic-bezier(0.3,1.25,0.5,1)] has-data-checked:-translate-y-0.75 has-data-checked:shadow-[inset_0_0_0_2px_var(--color-primary)] has-focus-visible:ring-3 has-focus-visible:ring-ring/50 motion-reduce:transition-none font-normal items-stretch"
          >
            {plan.popular && (
              <span className="absolute -top-2.25 start-4.5 rounded-full bg-primary px-2.25 py-0.5 text-xs font-semibold text-primary-foreground">
                Most popular
              </span>
            )}
            <span className="flex items-center gap-2">
              <span className="flex-1 text-2xl font-semibold">{plan.size}</span>
              <RadioGroupItem
                value={plan.size}
                aria-label={plan.size}
                className="size-5 focus-visible:ring-0"
              />
            </span>
            <span>
              <span className="text-2xl font-semibold tabular-nums">
                {money(yearly ? plan.price * 10 : plan.price)}
              </span>
              <span className="text-sm text-muted-foreground">
                {yearly ? " /yr" : " /mo"}
              </span>
            </span>
            <ul className="flex flex-col gap-1.5">
              {plan.features.map((feature) => (
                <li
                  key={feature}
                  className="flex items-start gap-1.5 text-sm leading-snug"
                >
                  <CheckIcon
                    className="mt-0.5 size-3.25 shrink-0 text-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_30%)] dark:text-green-500"
                  />
                  {feature}
                </li>
              ))}
            </ul>
          </Label>
        ))}
      </RadioGroup>
      <Button size="lg" onClick={() => setUpdated(true)} aria-live="polite">
        {updated ? "Your plan is updated" : `Switch to ${selected}`}
      </Button>
    </section>
  )
}
```

A cloud storage plan picker with a monthly or yearly switch, four plan cards with feature lists and a button that names the selected plan. One card carries a Most popular badge. Cards show one column on mobile, two on tablets and four on large screens. Use it for storage, seat or usage-based pricing.

## Installation

### CLI

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

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-storage
```

### 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), [Label](https://ui.suiss.app/docs/components/label), [Radio Group](https://ui.suiss.app/docs/components/radio-group), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

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

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

import { CheckIcon, CloudIcon } 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 { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

const plans = [
  {
    size: "50 GB",
    price: 0.99,
    features: ["Space for photos and files", "Private Relay", "Hide My Email"],
  },
  {
    size: "200 GB",
    price: 2.99,
    features: [
      "Share with your family",
      "Custom email domain",
      "Secure Video for 1 camera",
    ],
    popular: true,
  },
  {
    size: "2 TB",
    price: 9.99,
    features: ["Everything in 200 GB", "Unlimited Secure Video cameras"],
  },
  {
    size: "6 TB",
    price: 29.99,
    features: ["Everything in 2 TB", "Room for large archives"],
  },
]

const money = (value: number) =>
  value.toLocaleString("en-US", { style: "currency", currency: "USD" })

export function PriceStorage() {
  const [period, setPeriod] = React.useState("monthly")
  const [selected, setSelected] = React.useState("200 GB")
  const [updated, setUpdated] = React.useState(false)
  const yearly = period === "yearly"

  return (
    <section className="flex flex-col items-center gap-6 px-5 py-9 md:px-9">
      <div className="flex flex-col items-center gap-2 text-center">
        <CloudIcon className="size-10 text-blue-500" />
        <h1 className="text-4xl font-semibold tracking-tight text-balance md:text-5xl">
          More space with suiss Cloud+.
        </h1>
        <ToggleGroup
          spacing={0}
          value={[period]}
          onValueChange={(value: string[]) => {
            if (value[0]) setPeriod(value[0])
          }}
          aria-label="Billing period"
          className="mt-2"
        >
          <ToggleGroupItem value="monthly" size="sm" className="px-12">
            Monthly
          </ToggleGroupItem>
          <ToggleGroupItem value="yearly" size="sm">
            Yearly · 2 months free
          </ToggleGroupItem>
        </ToggleGroup>
      </div>
      <RadioGroup
        value={selected}
        onValueChange={(value) => {
          setSelected(value as string)
          setUpdated(false)
        }}
        aria-label="Storage plan"
        className="grid w-full max-w-240 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"
      >
        {plans.map((plan) => (
          <Label
            key={plan.size}
            className="relative flex cursor-pointer flex-col gap-3 rounded-[1.125rem] bg-muted p-4.5 transition-[translate,box-shadow] duration-350 ease-[cubic-bezier(0.3,1.25,0.5,1)] has-data-checked:-translate-y-0.75 has-data-checked:shadow-[inset_0_0_0_2px_var(--color-primary)] has-focus-visible:ring-3 has-focus-visible:ring-ring/50 motion-reduce:transition-none font-normal items-stretch"
          >
            {plan.popular && (
              <span className="absolute -top-2.25 start-4.5 rounded-full bg-primary px-2.25 py-0.5 text-xs font-semibold text-primary-foreground">
                Most popular
              </span>
            )}
            <span className="flex items-center gap-2">
              <span className="flex-1 text-2xl font-semibold">{plan.size}</span>
              <RadioGroupItem
                value={plan.size}
                aria-label={plan.size}
                className="size-5 focus-visible:ring-0"
              />
            </span>
            <span>
              <span className="text-2xl font-semibold tabular-nums">
                {money(yearly ? plan.price * 10 : plan.price)}
              </span>
              <span className="text-sm text-muted-foreground">
                {yearly ? " /yr" : " /mo"}
              </span>
            </span>
            <ul className="flex flex-col gap-1.5">
              {plan.features.map((feature) => (
                <li
                  key={feature}
                  className="flex items-start gap-1.5 text-sm leading-snug"
                >
                  <CheckIcon
                    className="mt-0.5 size-3.25 shrink-0 text-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_30%)] dark:text-green-500"
                  />
                  {feature}
                </li>
              ))}
            </ul>
          </Label>
        ))}
      </RadioGroup>
      <Button size="lg" onClick={() => setUpdated(true)} aria-live="polite">
        {updated ? "Your plan is updated" : `Switch to ${selected}`}
      </Button>
    </section>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { PriceStorage } from "@/components/blocks/price-storage"
```

```tsx
<PriceStorage />
```

Edit `plans`; each has a size, monthly price, a list of features and an optional popular flag. Yearly prices are ten times the monthly price to match the 2 months free label, so change both together. Replace the button handler, which only shows a confirmation, with your billing call.

**React Native, SwiftUI, Jetpack Compose**

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

```tsx title="uniwind/components/blocks/price-storage.tsx"
import { cn } from "cn"
import { CheckIcon, CloudIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, ScrollView, View } from "react-native"
import {
  Easing,
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} 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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Period = "monthly" | "yearly"

type Plan = {
  size: string
  price: number
  features: string[]
  popular?: boolean
}

const PLANS: Plan[] = [
  {
    size: "50 GB",
    price: 0.99,
    features: ["Space for photos and files", "Private Relay", "Hide My Email"],
  },
  {
    size: "200 GB",
    price: 2.99,
    features: [
      "Share with your family",
      "Custom email domain",
      "Secure Video for 1 camera",
    ],
    popular: true,
  },
  {
    size: "2 TB",
    price: 9.99,
    features: ["Everything in 200 GB", "Unlimited Secure Video cameras"],
  },
  {
    size: "6 TB",
    price: 29.99,
    features: ["Everything in 2 TB", "Room for large archives"],
  },
]

const EASING = Easing.bezier(0.3, 1.25, 0.5, 1)

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

function PlanCard({
  plan,
  checked,
  yearly,
  onPress,
}: {
  plan: Plan
  checked: boolean
  yearly: boolean
  onPress: () => void
}) {
  const lift = useSharedValue(checked ? -3 : 0)

  React.useEffect(() => {
    lift.value = withTiming(checked ? -3 : 0, {
      duration: 350,
      easing: EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [checked, lift])

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ translateY: lift.value }],
  }))

  return (
    <AnimatedView style={animatedStyle}>
      <Pressable
        onPress={onPress}
        className={cn(
          "gap-3 rounded-[1.125rem] border-2 bg-muted p-4.5",
          checked ? "border-primary" : "border-transparent active:opacity-80"
        )}
      >
        <View className="flex-row items-center gap-2">
          <Text className="flex-1 text-2xl font-semibold">{plan.size}</Text>
          <RadioGroupItem
            value={plan.size}
            accessibilityLabel={`${plan.size}${plan.popular ? ", most popular" : ""}`}
            className="size-5"
          />
        </View>
        <Text>
          <Text className="text-2xl font-semibold tabular-nums">
            {money(yearly ? plan.price * 10 : plan.price)}
          </Text>
          <Text className="text-sm text-muted-foreground">
            {yearly ? " /yr" : " /mo"}
          </Text>
        </Text>
        <View className="gap-1.5">
          {plan.features.map((feature) => (
            <View key={feature} className="flex-row items-start gap-1.5">
              <Icon
                as={CheckIcon}
                weight="bold"
                className="mt-0.75 size-3.25 shrink-0 text-green-600 dark:text-green-400"
              />
              <Text className="flex-1 text-sm">{feature}</Text>
            </View>
          ))}
        </View>
      </Pressable>
      {plan.popular ? (
        <View
          pointerEvents="none"
          className="absolute -top-2.25 start-4.5 rounded-full bg-primary px-2.25 py-0.5"
        >
          <Text className="text-xs font-semibold text-primary-foreground">
            Most popular
          </Text>
        </View>
      ) : null}
    </AnimatedView>
  )
}

function PriceStorage({
  onSelectPlan,
  onPeriodChange,
}: {
  onSelectPlan?: (size: string, period: Period) => void
  onPeriodChange?: (period: Period) => void
}) {
  const [period, setPeriod] = React.useState<Period>("monthly")
  const [selected, setSelected] = React.useState("200 GB")
  const [updated, setUpdated] = React.useState(false)
  const yearly = period === "yearly"

  const select = (size: string) => {
    setSelected(size)
    setUpdated(false)
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      <ScrollView contentContainerClassName="gap-6 px-5 pt-9 pb-6">
        <AnimatedView
          entering={FadeInDown.duration(600).reduceMotion(ReduceMotion.System)}
          className="items-center gap-2"
        >
          <Icon as={CloudIcon} className="size-10 text-blue-500" />
          <Text role="heading" className="text-center text-3xl font-semibold">
            More space with suiss Cloud+.
          </Text>
          <ToggleGroup
            spacing={0}
            size="sm"
            value={[period]}
            onValueChange={(value) => {
              const next = value[0]
              if (next !== "monthly" && next !== "yearly") return
              setPeriod(next)
              onPeriodChange?.(next)
            }}
            accessibilityLabel="Billing period"
            className="mt-2 self-center"
          >
            <ToggleGroupItem value="monthly" className="px-8">
              <Text>Monthly</Text>
            </ToggleGroupItem>
            <ToggleGroupItem value="yearly">
              <Text>Yearly · 2 months free</Text>
            </ToggleGroupItem>
          </ToggleGroup>
        </AnimatedView>
        <RadioGroup
          value={selected}
          onValueChange={select}
          accessibilityLabel="Storage plan"
          className="gap-3 pt-1"
        >
          {PLANS.map((plan) => (
            <PlanCard
              key={plan.size}
              plan={plan}
              checked={plan.size === selected}
              yearly={yearly}
              onPress={() => select(plan.size)}
            />
          ))}
        </RadioGroup>
      </ScrollView>
      <BottomBar separator>
        <BottomBarContent>
          <Button
            size="lg"
            block
            accessibilityLiveRegion="polite"
            onPress={() => {
              onSelectPlan?.(selected, period)
              setUpdated(true)
            }}
          >
            <Text>
              {updated ? "Your plan is updated" : `Switch to ${selected}`}
            </Text>
          </Button>
        </BottomBarContent>
      </BottomBar>
    </Screen>
  )
}

export { PriceStorage }
```

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

public struct PriceStorage: View {
  public enum Period: CaseIterable, Sendable {
    case monthly, yearly
  }

  struct Plan: Identifiable {
    let size: String
    let price: Double
    let features: [String]
    var popular = false

    var id: String { size }
  }

  static var plans: [Plan] {
    [
      Plan(size: "50 GB", price: 0.99, features: ["Space for photos and files", "Private Relay", "Hide My Email"]),
      Plan(size: "200 GB", price: 2.99, features: ["Share with your family", "Custom email domain", "Secure Video for 1 camera"], popular: true),
      Plan(size: "2 TB", price: 9.99, features: ["Everything in 200 GB", "Unlimited Secure Video cameras"]),
      Plan(size: "6 TB", price: 29.99, features: ["Everything in 2 TB", "Room for large archives"]),
    ]
  }

  private var onSelectPlan: ((String, Period) -> Void)?
  private var onPeriodChange: ((Period) -> Void)?
  @State private var period: Period
  @State private var selected: String
  @State private var updated: Bool
  @Environment(\.locale) private var locale
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    onSelectPlan: ((_ size: String, _ period: Period) -> Void)? = nil,
    onPeriodChange: ((_ period: Period) -> Void)? = nil
  ) {
    self.init(period: .monthly)
    self.onSelectPlan = onSelectPlan
    self.onPeriodChange = onPeriodChange
  }

  init(period: Period, selected: String = "200 GB", updated: Bool = false) {
    _period = State(initialValue: period)
    _selected = State(initialValue: selected)
    _updated = State(initialValue: updated)
  }

  private func money(_ value: Double) -> String {
    value.formatted(.currency(code: "USD").locale(locale))
  }

  public var body: some View {
    let yearly = period == .yearly
    Screen {
      KeyboardScrollView {
        VStack(spacing: 24) {
          VStack(spacing: 8) {
            Ph.cloud.regular
              .frame(width: 40, height: 40)
              .foregroundStyle(Palette.blue500)
              .accessibilityHidden(true)
            Text("More space with suiss Cloud+.")
              .scaledTextStyle(.xl3, weight: .semibold)
              .multilineTextAlignment(.center)
              .fixedSize(horizontal: false, vertical: true)
              .accessibilityAddTraits(.isHeader)
            ToggleGroup(selection: periodSelection, size: .sm, spacing: 0) {
              ToggleGroupItem("Monthly", value: Period.monthly)
                .frame(minWidth: 120)
              ToggleGroupItem("Yearly · 2 months free", value: Period.yearly)
            }
            .accessibilityLabel(Text("Billing period"))
            .padding(.top, 8)
          }
          RadioGroup(selection: $selected) {
            ForEach(Self.plans) { plan in
              PriceStorageCard(
                plan: plan,
                price: money(yearly ? plan.price * 10 : plan.price),
                yearly: yearly,
                selected: plan.size == selected
              ) {
                selected = plan.size
              }
            }
          }
          .padding(.top, 8)
          .accessibilityElement(children: .contain)
          .accessibilityLabel(Text("Storage plan"))
        }
        .padding(.horizontal, 20)
        .padding(.vertical, 36)
      }
      BottomBar {
        BottomBarContent {
          Button {
            onSelectPlan?(selected, period)
            updated = true
          } label: {
            if updated {
              Text("Your plan is updated")
            } else {
              Text("Switch to \(selected)")
            }
          }
          .buttonStyle(.variant(.default, size: .lg, block: true))
        }
      }
    }
    .animation(reduceMotion ? nil : .spring(response: 0.35, dampingFraction: 0.7), value: selected)
    .onChange(of: selected) { updated = false }
    .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: period)
  }

  private var periodSelection: Binding<Period?> {
    Binding(
      get: { period },
      set: { value in
        guard let value, value != period else { return }
        period = value
        onPeriodChange?(value)
      }
    )
  }
}

private struct PriceStorageCard: View {
  let plan: PriceStorage.Plan
  let price: String
  let yearly: Bool
  let selected: Bool
  let action: () -> Void

  var body: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
    Button(action: action) {
      VStack(alignment: .leading, spacing: 12) {
        HStack(spacing: 8) {
          Text(verbatim: plan.size)
            .scaledFont(.xl, weight: .semibold)
            .frame(maxWidth: .infinity, alignment: .leading)
          RadioGroupItem(value: plan.size)
            .accessibilityHidden(true)
        }
        HStack(alignment: .firstTextBaseline, spacing: 0) {
          Text(verbatim: price)
            .scaledFont(.xl, weight: .semibold)
            .monospacedDigit()
            .contentTransition(.numericText())
          Text(verbatim: " ")
          Group {
            if yearly {
              Text("/yr")
            } else {
              Text("/mo")
            }
          }
          .scaledFont(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
        }
        VStack(alignment: .leading, spacing: 6) {
          ForEach(plan.features, id: \.self) { feature in
            HStack(alignment: .firstTextBaseline, spacing: 6) {
              Ph.check.bold
                .frame(width: 13, height: 13)
                .foregroundStyle(Color.adaptive(light: Palette.green600, dark: Palette.green400))
                .alignmentGuide(.firstTextBaseline) { $0[.bottom] - 2 }
              Text(LocalizedStringKey(feature))
                .scaledFont(.sm)
                .fixedSize(horizontal: false, vertical: true)
            }
          }
        }
      }
      .padding(18)
      .foregroundStyle(EsuissColor.foreground)
      .frame(maxWidth: .infinity, alignment: .leading)
      .contentShape(shape)
    }
    .buttonStyle(PriceStorageCardStyle(selected: selected))
    .overlay(alignment: .topLeading) {
      if plan.popular {
        Text("Most popular")
          .scaledFont(.xs, weight: .semibold)
          .foregroundStyle(EsuissColor.primaryForeground)
          .padding(.horizontal, 9)
          .padding(.vertical, 2)
          .background(EsuissColor.primary, in: Capsule())
          .offset(x: 18, y: -9)
          .accessibilityHidden(true)
      }
    }
    .offset(y: selected ? -3 : 0)
    .accessibilityLabel(Text(verbatim: plan.size))
    .accessibilityValue(plan.popular ? Text("Most popular") : Text(verbatim: ""))
    .accessibilityAddTraits(selected ? .isSelected : [])
    .hapticFeedback(.selection, trigger: selected) { _, now in now }
  }
}

private struct PriceStorageCardStyle: ButtonStyle {
  let selected: Bool

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

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

import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
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.offset
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.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.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.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.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.withStyle
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.bold.Check
import com.adamglin.phosphoricons.regular.Cloud
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.ToggleGroup
import com.esuiss.ui.components.ToggleGroupItem
import com.esuiss.ui.components.ToggleSize
import com.esuiss.ui.components.rememberHaptics
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 val StorageLiftEasing = CubicBezierEasing(0.3f, 1.25f, 0.5f, 1f)

private class StoragePlan(val size: String, val price: Double, val features: List<String>, val popular: Boolean = false)

private val StoragePlans = listOf(
    StoragePlan("50 GB", 0.99, listOf("Space for photos and files", "Private Relay", "Hide My Email")),
    StoragePlan("200 GB", 2.99, listOf("Share with your family", "Custom email domain", "Secure Video for 1 camera"), popular = true),
    StoragePlan("2 TB", 9.99, listOf("Everything in 200 GB", "Unlimited Secure Video cameras")),
    StoragePlan("6 TB", 29.99, listOf("Everything in 2 TB", "Room for large archives")),
)

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

@Composable
fun PriceStorage(
    modifier: Modifier = Modifier,
    onSelectPlan: (plan: String, period: String) -> Unit = { _, _ -> },
    onPeriodChange: (period: String) -> Unit = {},
) {
    val colors = EsuissTheme.colors
    var period by rememberSaveable { mutableStateOf("Monthly") }
    var selected by rememberSaveable { mutableStateOf("200 GB") }
    var updated by rememberSaveable { mutableStateOf(false) }
    val yearly = period == "Yearly"

    Screen(modifier) {
        Column(Modifier.fillMaxSize()) {
            Column(
                Modifier
                    .weight(1f)
                    .verticalScroll(rememberScrollState())
                    .padding(horizontal = 20.dp, vertical = 36.dp),
                verticalArrangement = Arrangement.spacedBy(24.dp),
            ) {
                Column(
                    Modifier.fillMaxWidth(),
                    horizontalAlignment = Alignment.CenterHorizontally,
                    verticalArrangement = Arrangement.spacedBy(8.dp),
                ) {
                    Icon(PhosphorIcons.Regular.Cloud, contentDescription = null, size = 40.dp, tint = Palette.blue500)
                    BasicText(
                        "More space with suiss Cloud+.",
                        modifier = Modifier.semantics { heading() },
                        style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center),
                    )
                    ToggleGroup(
                        value = period,
                        onValueChange = { value ->
                            if (value != null && value != period) {
                                period = value
                                onPeriodChange(value)
                            }
                        },
                        modifier = Modifier
                            .padding(top = 8.dp)
                            .semantics { contentDescription = "Billing period" },
                        size = ToggleSize.Sm,
                        spacing = 0,
                    ) {
                        ToggleGroupItem("Monthly", "Monthly")
                        ToggleGroupItem("Yearly · 2 months free", "Yearly")
                    }
                }
                Column(
                    Modifier
                        .selectableGroup()
                        .semantics { contentDescription = "Storage plan" },
                    verticalArrangement = Arrangement.spacedBy(12.dp),
                ) {
                    StoragePlans.forEach { plan ->
                        StorageCard(plan, yearly, selected = plan.size == selected) {
                            selected = plan.size
                            updated = false
                        }
                    }
                }
            }
            BottomBar(separator = true) {
                BottomBarContent {
                    Button(
                        if (updated) "Your plan is updated" else "Switch to $selected",
                        {
                            onSelectPlan(selected, period)
                            updated = true
                        },
                        modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
                        size = ButtonSize.Lg,
                        block = true,
                    )
                }
            }
        }
    }
}

@Composable
private fun StorageCard(plan: StoragePlan, yearly: Boolean, selected: Boolean, onSelect: () -> Unit) {
    val colors = EsuissTheme.colors
    val haptics = rememberHaptics()
    val shape = RoundedCornerShape(18.dp)
    val lift by animateDpAsState(if (selected) (-3).dp else 0.dp, tween(350, easing = StorageLiftEasing), label = "lift")
    val border by animateColorAsState(if (selected) colors.primary else Color.Transparent, tween(200), label = "border")
    Box(
        Modifier
            .fillMaxWidth()
            .padding(top = if (plan.popular) 9.dp else 0.dp)
            .offset(y = lift),
    ) {
        Column(
            Modifier
                .fillMaxWidth()
                .background(colors.muted, shape)
                .border(2.dp, border, shape)
                .selectable(
                    selected = selected,
                    interactionSource = remember { MutableInteractionSource() },
                    indication = null,
                    role = Role.RadioButton,
                    onClick = {
                        if (!selected) haptics.selection()
                        onSelect()
                    },
                )
                .padding(18.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
                BasicText(
                    plan.size,
                    modifier = Modifier.weight(1f),
                    style = Typography.xl2.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                )
                StorageDot(selected)
            }
            BasicText(
                buildAnnotatedString {
                    withStyle(SpanStyle(fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum")) {
                        append(storageMoney(if (yearly) plan.price * 10 else plan.price))
                    }
                    withStyle(Typography.sm.toSpanStyle().copy(color = colors.mutedForeground)) { append(if (yearly) " /yr" else " /mo") }
                },
                style = Typography.xl2.copy(color = colors.foreground),
            )
            Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
                plan.features.forEach { feature ->
                    Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
                        Icon(
                            PhosphorIcons.Bold.Check,
                            contentDescription = null,
                            modifier = Modifier.padding(top = 3.dp),
                            size = 13.dp,
                            tint = if (colors.isDark) Palette.green400 else Palette.green600,
                        )
                        BasicText(feature, style = Typography.sm.copy(color = colors.foreground))
                    }
                }
            }
        }
        if (plan.popular) {
            BasicText(
                "Most popular",
                modifier = Modifier
                    .offset(x = 18.dp, y = (-9).dp)
                    .background(colors.primary, CircleShape)
                    .padding(horizontal = 9.dp, vertical = 2.dp),
                style = Typography.xs.copy(color = colors.primaryForeground, fontWeight = FontWeight.SemiBold),
            )
        }
    }
}

@Composable
private fun StorageDot(selected: Boolean) {
    val colors = EsuissTheme.colors
    Box(
        Modifier
            .size(20.dp)
            .background(if (selected) colors.primary else colors.background, CircleShape)
            .border(1.dp, if (selected) colors.primary else colors.input, CircleShape)
            .clearAndSetSemantics {},
        contentAlignment = Alignment.Center,
    ) {
        if (selected) {
            Box(
                Modifier
                    .size(8.dp)
                    .background(colors.primaryForeground, CircleShape),
            )
        }
    }
}
```

A pricing screen with a `ToggleGroup` for the billing period and a `RadioGroup` of plan cards, each with a size, price and feature list. The selected card lifts slightly, and the most popular plan carries a label. Yearly prices show ten months of the monthly price. A `BottomBar` `Button` confirms the switch.

## Installation

**React Native**

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

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-storage
```

**SwiftUI**

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

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add price-storage --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), [Radio Group](https://ui.suiss.app/docs/components/radio-group), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group). 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), [Radio Group](https://ui.suiss.app/docs/components/radio-group), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group), 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-storage.tsx`:

- `uniwind/components/blocks/price-storage.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), [Radio Group](https://ui.suiss.app/docs/components/radio-group), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group), 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-storage.tsx`:

```tsx title="nativewind/components/blocks/price-storage.tsx"
import { cn } from "cn"
import { CheckIcon, CloudIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, ScrollView, View } from "react-native"
import {
  Easing,
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} 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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Period = "monthly" | "yearly"

type Plan = {
  size: string
  price: number
  features: string[]
  popular?: boolean
}

const PLANS: Plan[] = [
  {
    size: "50 GB",
    price: 0.99,
    features: ["Space for photos and files", "Private Relay", "Hide My Email"],
  },
  {
    size: "200 GB",
    price: 2.99,
    features: [
      "Share with your family",
      "Custom email domain",
      "Secure Video for 1 camera",
    ],
    popular: true,
  },
  {
    size: "2 TB",
    price: 9.99,
    features: ["Everything in 200 GB", "Unlimited Secure Video cameras"],
  },
  {
    size: "6 TB",
    price: 29.99,
    features: ["Everything in 2 TB", "Room for large archives"],
  },
]

const EASING = Easing.bezier(0.3, 1.25, 0.5, 1)

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

function PlanCard({
  plan,
  checked,
  yearly,
  onPress,
}: {
  plan: Plan
  checked: boolean
  yearly: boolean
  onPress: () => void
}) {
  const lift = useSharedValue(checked ? -3 : 0)

  React.useEffect(() => {
    lift.value = withTiming(checked ? -3 : 0, {
      duration: 350,
      easing: EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [checked, lift])

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ translateY: lift.value }],
  }))

  return (
    <AnimatedView style={animatedStyle}>
      <Pressable
        onPress={onPress}
        className={cn(
          "gap-3 rounded-[1.125rem] border-2 bg-muted p-[1.125rem]",
          checked ? "border-primary" : "border-transparent active:opacity-80"
        )}
      >
        <View className="flex-row items-center gap-2">
          <Text className="flex-1 text-2xl font-semibold">{plan.size}</Text>
          <RadioGroupItem
            value={plan.size}
            accessibilityLabel={`${plan.size}${plan.popular ? ", most popular" : ""}`}
            className="size-5"
          />
        </View>
        <Text>
          <Text className="text-2xl font-semibold tabular-nums">
            {money(yearly ? plan.price * 10 : plan.price)}
          </Text>
          <Text className="text-sm text-muted-foreground">
            {yearly ? " /yr" : " /mo"}
          </Text>
        </Text>
        <View className="gap-1.5">
          {plan.features.map((feature) => (
            <View key={feature} className="flex-row items-start gap-1.5">
              <Icon
                as={CheckIcon}
                weight="bold"
                className="mt-[0.1875rem] size-[0.8125rem] shrink-0 text-green-600 dark:text-green-400"
              />
              <Text className="flex-1 text-sm">{feature}</Text>
            </View>
          ))}
        </View>
      </Pressable>
      {plan.popular ? (
        <View
          pointerEvents="none"
          className="absolute -top-[0.5625rem] start-[1.125rem] rounded-full bg-primary px-[0.5625rem] py-0.5"
        >
          <Text className="text-xs font-semibold text-primary-foreground">
            Most popular
          </Text>
        </View>
      ) : null}
    </AnimatedView>
  )
}

function PriceStorage({
  onSelectPlan,
  onPeriodChange,
}: {
  onSelectPlan?: (size: string, period: Period) => void
  onPeriodChange?: (period: Period) => void
}) {
  const [period, setPeriod] = React.useState<Period>("monthly")
  const [selected, setSelected] = React.useState("200 GB")
  const [updated, setUpdated] = React.useState(false)
  const yearly = period === "yearly"

  const select = (size: string) => {
    setSelected(size)
    setUpdated(false)
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      <ScrollView contentContainerClassName="gap-6 px-5 pt-9 pb-6">
        <AnimatedView
          entering={FadeInDown.duration(600).reduceMotion(ReduceMotion.System)}
          className="items-center gap-2"
        >
          <Icon as={CloudIcon} className="size-10 text-blue-500" />
          <Text role="heading" className="text-center text-3xl font-semibold">
            More space with suiss Cloud+.
          </Text>
          <ToggleGroup
            spacing={0}
            size="sm"
            value={[period]}
            onValueChange={(value) => {
              const next = value[0]
              if (next !== "monthly" && next !== "yearly") return
              setPeriod(next)
              onPeriodChange?.(next)
            }}
            accessibilityLabel="Billing period"
            className="mt-2 self-center"
          >
            <ToggleGroupItem value="monthly" className="px-8">
              <Text>Monthly</Text>
            </ToggleGroupItem>
            <ToggleGroupItem value="yearly">
              <Text>Yearly · 2 months free</Text>
            </ToggleGroupItem>
          </ToggleGroup>
        </AnimatedView>
        <RadioGroup
          value={selected}
          onValueChange={select}
          accessibilityLabel="Storage plan"
          className="gap-3 pt-1"
        >
          {PLANS.map((plan) => (
            <PlanCard
              key={plan.size}
              plan={plan}
              checked={plan.size === selected}
              yearly={yearly}
              onPress={() => select(plan.size)}
            />
          ))}
        </RadioGroup>
      </ScrollView>
      <BottomBar separator>
        <BottomBarContent>
          <Button
            size="lg"
            block
            accessibilityLiveRegion="polite"
            onPress={() => {
              onSelectPlan?.(selected, period)
              setUpdated(true)
            }}
          >
            <Text>
              {updated ? "Your plan is updated" : `Switch to ${selected}`}
            </Text>
          </Button>
        </BottomBarContent>
      </BottomBar>
    </Screen>
  )
}

export { PriceStorage }
```

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), [Radio Group](https://ui.suiss.app/docs/components/radio-group), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group), 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-storage.tsx`:

```tsx title="stylesheet/components/blocks/price-storage.tsx"
import { CheckIcon, CloudIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, ScrollView, StyleSheet, View } from "react-native"
import {
  Easing,
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} 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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import { fontSize, palette, useTheme } from "@/components/ui/theme"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Period = "monthly" | "yearly"

type Plan = {
  size: string
  price: number
  features: string[]
  popular?: boolean
}

const PLANS: Plan[] = [
  {
    size: "50 GB",
    price: 0.99,
    features: ["Space for photos and files", "Private Relay", "Hide My Email"],
  },
  {
    size: "200 GB",
    price: 2.99,
    features: [
      "Share with your family",
      "Custom email domain",
      "Secure Video for 1 camera",
    ],
    popular: true,
  },
  {
    size: "2 TB",
    price: 9.99,
    features: ["Everything in 200 GB", "Unlimited Secure Video cameras"],
  },
  {
    size: "6 TB",
    price: 29.99,
    features: ["Everything in 2 TB", "Room for large archives"],
  },
]

const EASING = Easing.bezier(0.3, 1.25, 0.5, 1)

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

const styles = StyleSheet.create({
  content: {
    gap: 24,
    paddingHorizontal: 20,
    paddingTop: 36,
    paddingBottom: 24,
  },
  intro: { alignItems: "center", gap: 8 },
  title: { ...fontSize["3xl"], fontWeight: "600", textAlign: "center" },
  period: { marginTop: 8, alignSelf: "center" },
  monthly: { paddingHorizontal: 32 },
  plans: { gap: 12, paddingTop: 4 },
  card: { gap: 12, borderRadius: 18, borderWidth: 2, padding: 18 },
  cardHead: { flexDirection: "row", alignItems: "center", gap: 8 },
  size: { ...fontSize["2xl"], flex: 1, fontWeight: "600" },
  radio: { width: 20, height: 20 },
  price: {
    ...fontSize["2xl"],
    fontWeight: "600",
    fontVariant: ["tabular-nums"],
  },
  features: { gap: 6 },
  feature: { flexDirection: "row", alignItems: "flex-start", gap: 6 },
  check: { marginTop: 3, flexShrink: 0 },
  badge: {
    position: "absolute",
    top: -9,
    start: 18,
    borderRadius: 9999,
    paddingHorizontal: 9,
    paddingVertical: 2,
  },
  badgeText: { ...fontSize.xs, fontWeight: "600" },
})

function PlanCard({
  plan,
  checked,
  yearly,
  onPress,
}: {
  plan: Plan
  checked: boolean
  yearly: boolean
  onPress: () => void
}) {
  const { colors, isDark } = useTheme()
  const lift = useSharedValue(checked ? -3 : 0)

  React.useEffect(() => {
    lift.value = withTiming(checked ? -3 : 0, {
      duration: 350,
      easing: EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [checked, lift])

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ translateY: lift.value }],
  }))

  return (
    <AnimatedView style={animatedStyle}>
      <Pressable
        onPress={onPress}
        style={({ pressed }) => [
          styles.card,
          {
            backgroundColor: colors.muted,
            borderColor: checked ? colors.primary : "transparent",
            opacity: pressed && !checked ? 0.8 : 1,
          },
        ]}
      >
        <View style={styles.cardHead}>
          <Text style={styles.size}>{plan.size}</Text>
          <RadioGroupItem
            value={plan.size}
            accessibilityLabel={`${plan.size}${plan.popular ? ", most popular" : ""}`}
            style={styles.radio}
          />
        </View>
        <Text>
          <Text style={styles.price}>
            {money(yearly ? plan.price * 10 : plan.price)}
          </Text>
          <Text style={[fontSize.sm, { color: colors.mutedForeground }]}>
            {yearly ? " /yr" : " /mo"}
          </Text>
        </Text>
        <View style={styles.features}>
          {plan.features.map((feature) => (
            <View key={feature} style={styles.feature}>
              <Icon
                as={CheckIcon}
                weight="bold"
                size={13}
                color={isDark ? palette.green400 : palette.green600}
                style={styles.check}
              />
              <Text style={[fontSize.sm, { flex: 1 }]}>{feature}</Text>
            </View>
          ))}
        </View>
      </Pressable>
      {plan.popular ? (
        <View
          pointerEvents="none"
          style={[styles.badge, { backgroundColor: colors.primary }]}
        >
          <Text style={[styles.badgeText, { color: colors.primaryForeground }]}>
            Most popular
          </Text>
        </View>
      ) : null}
    </AnimatedView>
  )
}

function PriceStorage({
  onSelectPlan,
  onPeriodChange,
}: {
  onSelectPlan?: (size: string, period: Period) => void
  onPeriodChange?: (period: Period) => void
}) {
  const [period, setPeriod] = React.useState<Period>("monthly")
  const [selected, setSelected] = React.useState("200 GB")
  const [updated, setUpdated] = React.useState(false)
  const yearly = period === "yearly"

  const select = (size: string) => {
    setSelected(size)
    setUpdated(false)
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      <ScrollView contentContainerStyle={styles.content}>
        <AnimatedView
          entering={FadeInDown.duration(600).reduceMotion(ReduceMotion.System)}
          style={styles.intro}
        >
          <Icon as={CloudIcon} size={40} color={palette.blue500} />
          <Text role="heading" style={styles.title}>
            More space with suiss Cloud+.
          </Text>
          <ToggleGroup
            spacing={0}
            size="sm"
            value={[period]}
            onValueChange={(value) => {
              const next = value[0]
              if (next !== "monthly" && next !== "yearly") return
              setPeriod(next)
              onPeriodChange?.(next)
            }}
            accessibilityLabel="Billing period"
            style={styles.period}
          >
            <ToggleGroupItem value="monthly" style={styles.monthly}>
              <Text>Monthly</Text>
            </ToggleGroupItem>
            <ToggleGroupItem value="yearly">
              <Text>Yearly · 2 months free</Text>
            </ToggleGroupItem>
          </ToggleGroup>
        </AnimatedView>
        <RadioGroup
          value={selected}
          onValueChange={select}
          accessibilityLabel="Storage plan"
          style={styles.plans}
        >
          {PLANS.map((plan) => (
            <PlanCard
              key={plan.size}
              plan={plan}
              checked={plan.size === selected}
              yearly={yearly}
              onPress={() => select(plan.size)}
            />
          ))}
        </RadioGroup>
      </ScrollView>
      <BottomBar separator>
        <BottomBarContent>
          <Button
            size="lg"
            block
            accessibilityLiveRegion="polite"
            onPress={() => {
              onSelectPlan?.(selected, period)
              setUpdated(true)
            }}
          >
            <Text>
              {updated ? "Your plan is updated" : `Switch to ${selected}`}
            </Text>
          </Button>
        </BottomBarContent>
      </BottomBar>
    </Screen>
  )
}

export { PriceStorage }
```

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

- `swift/Components/DropdownMenu.swift`: see [Dropdown Menu](https://ui.suiss.app/docs/components/dropdown-menu.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.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/Label.swift`: see [Label](https://ui.suiss.app/docs/components/label.md)

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

- `swift/Components/RadioGroup.swift`: see [Radio Group](https://ui.suiss.app/docs/components/radio-group.md)

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

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

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

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

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

- `swift/Components/ToggleGroup.swift`: see [Toggle Group](https://ui.suiss.app/docs/components/toggle-group.md)

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/PriceStorage.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/Checkbox.kt`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.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/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

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

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

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

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

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

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

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

- `kotlin/components/ToggleGroup.kt`: see [Toggle Group](https://ui.suiss.app/docs/components/toggle-group.md)

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

## Usage

```tsx
import { PriceStorage } from "@/components/blocks/price-storage"
```

```tsx
<PriceStorage />
```

Replace `PLANS` with your plans. Wire `onSelectPlan` (receives the size and period) and `onPeriodChange` to your billing flow.

