# Bag Configure

A product configurator for choosing a model, finish, and storage, with a live price and Add to Bag button.

Bag Configure 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/bag-configure

**React**

**Example**

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

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 photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?w=1000&q=80&auto=format&fit=crop`

type Model = { name: string; price: number }

type Finish = { name: string; color: string; image: string; alt: string }

type StorageOption = { size: string; extra: number }

const models: [Model, ...Model[]] = [
  { name: "Phone Pro", price: 1099 },
  { name: "Phone Pro Max", price: 1199 },
]

const finishes: [Finish, ...Finish[]] = [
  {
    name: "Cosmic Orange",
    color: "oklch(0.68 0.16 50)",
    image: photo("1714972384975-fbd4f19b1bb3"),
    alt: "An orange phone on a warm gradient",
  },
  {
    name: "Deep Blue",
    color: "oklch(0.38 0.07 262)",
    image: photo("1714578187196-29775454aa39"),
    alt: "A phone floating among blue spheres",
  },
  {
    name: "Silver",
    color: "oklch(0.9 0.006 260)",
    image: photo("1664883410931-2edffc8be5e6"),
    alt: "A silver phone on a light grey background",
  },
]

const storage: [StorageOption, ...StorageOption[]] = [
  { size: "256 GB", extra: 0 },
  { size: "512 GB", extra: 200 },
  { size: "1 TB", extra: 400 },
]

const choiceClass =
  "cursor-pointer rounded-[0.875rem] font-normal shadow-[inset_0_0_0_1px_var(--color-input)] transition-shadow duration-200 has-data-checked:shadow-[inset_0_0_0_2px_var(--color-primary)] has-focus-visible:ring-3 has-focus-visible:ring-ring/50"

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

export function BagConfigure() {
  const [model, setModel] = React.useState(models[0].name)
  const [finish, setFinish] = React.useState(finishes[0].name)
  const [size, setSize] = React.useState(storage[0].size)
  const [added, setAdded] = React.useState(false)
  const price =
    (models.find((item) => item.name === model)?.price ?? 0) +
    (storage.find((item) => item.size === size)?.extra ?? 0)

  return (
    <section className="grid min-h-160 bg-background md:grid-cols-[minmax(0,1.3fr)_minmax(18.75rem,1fr)]">
      <div className="relative m-5 aspect-4/3 overflow-hidden rounded-[1.375rem] bg-muted md:aspect-auto">
        {finishes.map((item) => (
          <img
            key={item.name}
            src={item.image}
            alt={item.alt}
            aria-hidden={item.name !== finish}
            data-active={item.name === finish ? "" : undefined}
            className="absolute inset-0 size-full scale-104 object-cover opacity-0 transition-[opacity,scale] duration-[600ms,900ms] ease-[ease,cubic-bezier(0.32,0.72,0,1)] data-active:scale-100 data-active:opacity-100 motion-reduce:transition-none"
          />
        ))}
      </div>
      <div className="flex flex-col gap-4 px-5 pb-6 md:py-6.5 md:ps-2 md:pe-7">
        <p className="text-xs font-semibold text-[color-mix(in_oklab,var(--color-orange-500),var(--foreground)_45%)] dark:text-orange-500">
          New
        </p>
        <h1 className="-mt-2.5 text-4xl font-semibold tracking-tight">
          Buy Phone Pro
        </h1>
        <p className="text-lg font-semibold">
          Model.{" "}
          <span className="text-muted-foreground">
            Which one is right for you?
          </span>
        </p>
        <RadioGroup
          value={model}
          onValueChange={(value) => {
            setModel(value as string)
            setAdded(false)
          }}
          aria-label="Model"
          className="gap-2"
        >
          {models.map((item) => (
            <Label
              key={item.name}
              className={`${choiceClass} flex items-center justify-between gap-0 px-4 py-3.5`}
            >
              <RadioGroupItem value={item.name} className="absolute sr-only" />
              <span className="flex-1 text-base font-semibold">
                {item.name}
              </span>
              <span className="text-sm text-muted-foreground">
                From {money(item.price, 0)}
              </span>
            </Label>
          ))}
        </RadioGroup>
        <p className="text-lg font-semibold">
          Finish. <span className="text-muted-foreground">{finish}</span>
        </p>
        <ToggleGroup
          value={[finish]}
          onValueChange={(value: string[]) => {
            if (value[0]) {
              setFinish(value[0])
              setAdded(false)
            }
          }}
          aria-label="Finish"
          spacing={3.5}
        >
          {finishes.map((item) => (
            <ToggleGroupItem
              key={item.name}
              value={item.name}
              aria-label={item.name}
              style={{ "--swatch": item.color } as React.CSSProperties}
              className="size-7.5 min-w-0 rounded-full bg-(--swatch) p-0 shadow-[inset_0_0_0_1px_--alpha(var(--color-foreground)/15%)] transition-shadow duration-200 hover:bg-(--swatch) aria-pressed:bg-(--swatch) aria-pressed:ring-2 aria-pressed:ring-primary aria-pressed:ring-offset-2 aria-pressed:ring-offset-background aria-pressed:hover:bg-(--swatch)"
            />
          ))}
        </ToggleGroup>
        <p className="text-lg font-semibold">Storage.</p>
        <RadioGroup
          value={size}
          onValueChange={(value) => {
            setSize(value as string)
            setAdded(false)
          }}
          aria-label="Storage"
          className="grid grid-cols-3 gap-2"
        >
          {storage.map((item) => (
            <Label
              key={item.size}
              className={`${choiceClass} flex flex-col items-center gap-0.5 px-1.5 py-3`}
            >
              <RadioGroupItem value={item.size} className="absolute sr-only" />
              <span className="text-base font-semibold whitespace-nowrap">
                {item.size}
              </span>
              <span className="text-xs whitespace-nowrap text-muted-foreground">
                {item.extra ? `+${money(item.extra, 0)}` : "Included"}
              </span>
            </Label>
          ))}
        </RadioGroup>
        <div className="sticky bottom-0 mt-auto flex items-center gap-3 border-t border-border bg-background pt-3.5">
          <div className="flex flex-1 flex-col" aria-live="polite">
            <span className="text-2xl font-semibold tabular-nums">
              {money(price)}
            </span>
            <span className="text-xs text-muted-foreground">
              or {money(price / 12)}/mo. for 12 mo.
            </span>
          </div>
          <Button size="lg" onClick={() => setAdded(true)}>
            {added ? "Added to Bag" : "Add to Bag"}
          </Button>
        </div>
      </div>
    </section>
  )
}
```

A buy page where shoppers pick a model, a finish and a storage size while the product photo and price update to match. On desktop the photo sits beside the options; on mobile it shrinks to a panel above them, and a sticky footer keeps the price and Add to Bag button in reach. Good for any configurable product, such as hardware, tiered subscriptions or made-to-order goods.

## Installation

### CLI

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

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

### Manual

1. 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).

2. Copy the source into `components/blocks/bag-configure.tsx`:

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

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 photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?w=1000&q=80&auto=format&fit=crop`

type Model = { name: string; price: number }

type Finish = { name: string; color: string; image: string; alt: string }

type StorageOption = { size: string; extra: number }

const models: [Model, ...Model[]] = [
  { name: "Phone Pro", price: 1099 },
  { name: "Phone Pro Max", price: 1199 },
]

const finishes: [Finish, ...Finish[]] = [
  {
    name: "Cosmic Orange",
    color: "oklch(0.68 0.16 50)",
    image: photo("1714972384975-fbd4f19b1bb3"),
    alt: "An orange phone on a warm gradient",
  },
  {
    name: "Deep Blue",
    color: "oklch(0.38 0.07 262)",
    image: photo("1714578187196-29775454aa39"),
    alt: "A phone floating among blue spheres",
  },
  {
    name: "Silver",
    color: "oklch(0.9 0.006 260)",
    image: photo("1664883410931-2edffc8be5e6"),
    alt: "A silver phone on a light grey background",
  },
]

const storage: [StorageOption, ...StorageOption[]] = [
  { size: "256 GB", extra: 0 },
  { size: "512 GB", extra: 200 },
  { size: "1 TB", extra: 400 },
]

const choiceClass =
  "cursor-pointer rounded-[0.875rem] font-normal shadow-[inset_0_0_0_1px_var(--color-input)] transition-shadow duration-200 has-data-checked:shadow-[inset_0_0_0_2px_var(--color-primary)] has-focus-visible:ring-3 has-focus-visible:ring-ring/50"

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

export function BagConfigure() {
  const [model, setModel] = React.useState(models[0].name)
  const [finish, setFinish] = React.useState(finishes[0].name)
  const [size, setSize] = React.useState(storage[0].size)
  const [added, setAdded] = React.useState(false)
  const price =
    (models.find((item) => item.name === model)?.price ?? 0) +
    (storage.find((item) => item.size === size)?.extra ?? 0)

  return (
    <section className="grid min-h-160 bg-background md:grid-cols-[minmax(0,1.3fr)_minmax(18.75rem,1fr)]">
      <div className="relative m-5 aspect-4/3 overflow-hidden rounded-[1.375rem] bg-muted md:aspect-auto">
        {finishes.map((item) => (
          <img
            key={item.name}
            src={item.image}
            alt={item.alt}
            aria-hidden={item.name !== finish}
            data-active={item.name === finish ? "" : undefined}
            className="absolute inset-0 size-full scale-104 object-cover opacity-0 transition-[opacity,scale] duration-[600ms,900ms] ease-[ease,cubic-bezier(0.32,0.72,0,1)] data-active:scale-100 data-active:opacity-100 motion-reduce:transition-none"
          />
        ))}
      </div>
      <div className="flex flex-col gap-4 px-5 pb-6 md:py-6.5 md:ps-2 md:pe-7">
        <p className="text-xs font-semibold text-[color-mix(in_oklab,var(--color-orange-500),var(--foreground)_45%)] dark:text-orange-500">
          New
        </p>
        <h1 className="-mt-2.5 text-4xl font-semibold tracking-tight">
          Buy Phone Pro
        </h1>
        <p className="text-lg font-semibold">
          Model.{" "}
          <span className="text-muted-foreground">
            Which one is right for you?
          </span>
        </p>
        <RadioGroup
          value={model}
          onValueChange={(value) => {
            setModel(value as string)
            setAdded(false)
          }}
          aria-label="Model"
          className="gap-2"
        >
          {models.map((item) => (
            <Label
              key={item.name}
              className={`${choiceClass} flex items-center justify-between gap-0 px-4 py-3.5`}
            >
              <RadioGroupItem value={item.name} className="absolute sr-only" />
              <span className="flex-1 text-base font-semibold">
                {item.name}
              </span>
              <span className="text-sm text-muted-foreground">
                From {money(item.price, 0)}
              </span>
            </Label>
          ))}
        </RadioGroup>
        <p className="text-lg font-semibold">
          Finish. <span className="text-muted-foreground">{finish}</span>
        </p>
        <ToggleGroup
          value={[finish]}
          onValueChange={(value: string[]) => {
            if (value[0]) {
              setFinish(value[0])
              setAdded(false)
            }
          }}
          aria-label="Finish"
          spacing={3.5}
        >
          {finishes.map((item) => (
            <ToggleGroupItem
              key={item.name}
              value={item.name}
              aria-label={item.name}
              style={{ "--swatch": item.color } as React.CSSProperties}
              className="size-7.5 min-w-0 rounded-full bg-(--swatch) p-0 shadow-[inset_0_0_0_1px_--alpha(var(--color-foreground)/15%)] transition-shadow duration-200 hover:bg-(--swatch) aria-pressed:bg-(--swatch) aria-pressed:ring-2 aria-pressed:ring-primary aria-pressed:ring-offset-2 aria-pressed:ring-offset-background aria-pressed:hover:bg-(--swatch)"
            />
          ))}
        </ToggleGroup>
        <p className="text-lg font-semibold">Storage.</p>
        <RadioGroup
          value={size}
          onValueChange={(value) => {
            setSize(value as string)
            setAdded(false)
          }}
          aria-label="Storage"
          className="grid grid-cols-3 gap-2"
        >
          {storage.map((item) => (
            <Label
              key={item.size}
              className={`${choiceClass} flex flex-col items-center gap-0.5 px-1.5 py-3`}
            >
              <RadioGroupItem value={item.size} className="absolute sr-only" />
              <span className="text-base font-semibold whitespace-nowrap">
                {item.size}
              </span>
              <span className="text-xs whitespace-nowrap text-muted-foreground">
                {item.extra ? `+${money(item.extra, 0)}` : "Included"}
              </span>
            </Label>
          ))}
        </RadioGroup>
        <div className="sticky bottom-0 mt-auto flex items-center gap-3 border-t border-border bg-background pt-3.5">
          <div className="flex flex-1 flex-col" aria-live="polite">
            <span className="text-2xl font-semibold tabular-nums">
              {money(price)}
            </span>
            <span className="text-xs text-muted-foreground">
              or {money(price / 12)}/mo. for 12 mo.
            </span>
          </div>
          <Button size="lg" onClick={() => setAdded(true)}>
            {added ? "Added to Bag" : "Add to Bag"}
          </Button>
        </div>
      </div>
    </section>
  )
}
```

3. Update the import paths to match your project.

## Usage

```tsx
import { BagConfigure } from "@/components/blocks/bag-configure"
```

```tsx
<BagConfigure />
```

Edit the `models`, `finishes` and `storage` lists: models carry a base price, finishes a swatch color and photo, and storage options a surcharge. The price is the model price plus the storage surcharge, with a 12-month estimate below it. Replace the Add to Bag handler, which only flips a local flag, with your cart call, and update the heading copy.

## Notes

Price changes are announced to screen readers, and each swatch is labeled with its finish name. Photos are Unsplash placeholders.

**React Native, SwiftUI, Jetpack Compose**

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

```tsx title="uniwind/components/blocks/bag-configure.tsx"
import { cn } from "cn"
import * as React from "react"
import { Image, Pressable, ScrollView, View } from "react-native"
import {
  Easing,
  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 { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

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

type Model = { name: string; price: number }

type Finish = { name: string; swatch: string; image: string; alt: string }

type StorageOption = { size: string; extra: number }

const MODELS: [Model, ...Model[]] = [
  { name: "Phone Pro", price: 1099 },
  { name: "Phone Pro Max", price: 1199 },
]

const FINISHES: [Finish, ...Finish[]] = [
  {
    name: "Cosmic Orange",
    swatch: "bg-orange-500",
    image: photo("1714972384975-fbd4f19b1bb3"),
    alt: "An orange phone on a warm gradient",
  },
  {
    name: "Deep Blue",
    swatch: "bg-indigo-500",
    image: photo("1714578187196-29775454aa39"),
    alt: "A phone floating among blue spheres",
  },
  {
    name: "Silver",
    swatch: "bg-zinc-500",
    image: photo("1664883410931-2edffc8be5e6"),
    alt: "A silver phone on a light grey background",
  },
]

const STORAGE: [StorageOption, ...StorageOption[]] = [
  { size: "256 GB", extra: 0 },
  { size: "512 GB", extra: 200 },
  { size: "1 TB", extra: 400 },
]

const EASING = Easing.bezier(0.32, 0.72, 0, 1)

const THOUSANDS = /\B(?=(\d{3})+(?!\d))/g

function 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 = "0", fraction] = value.toFixed(digits).split(".")
    return `$${whole.replace(THOUSANDS, ",")}${fraction ? `.${fraction}` : ""}`
  }
}

const CHOICE_CLASS = "rounded-[0.875rem] active:opacity-80"

const choiceBorder = (checked: boolean) =>
  checked ? "border-2 border-primary" : "border border-input"

function FinishImage({ finish, active }: { finish: Finish; active: boolean }) {
  const progress = useSharedValue(active ? 1 : 0)

  React.useEffect(() => {
    progress.value = withTiming(active ? 1 : 0, {
      duration: 700,
      easing: EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [active, progress])

  const animatedStyle = useAnimatedStyle(() => ({
    opacity: progress.value,
    transform: [{ scale: 1.04 - progress.value * 0.04 }],
  }))

  return (
    <AnimatedView
      pointerEvents="none"
      accessibilityElementsHidden={!active}
      importantForAccessibility={active ? "auto" : "no-hide-descendants"}
      className="absolute inset-0"
      style={animatedStyle}
    >
      <Image
        source={{ uri: finish.image }}
        resizeMode="cover"
        accessible
        accessibilityLabel={finish.alt}
        accessibilityIgnoresInvertColors
        className="size-full"
      />
    </AnimatedView>
  )
}

function BagConfigure({
  onAddToBag,
}: {
  onAddToBag?: (configuration: {
    model: string
    finish: string
    storage: string
    price: number
  }) => void
}) {
  const [model, setModel] = React.useState(MODELS[0].name)
  const [finish, setFinish] = React.useState(FINISHES[0].name)
  const [size, setSize] = React.useState(STORAGE[0].size)
  const [added, setAdded] = React.useState(false)
  const price =
    (MODELS.find((item) => item.name === model)?.price ?? 0) +
    (STORAGE.find((item) => item.size === size)?.extra ?? 0)

  return (
    <Screen edges={["top", "right", "left"]}>
      <ScrollView contentContainerClassName="gap-4 px-5 pt-5 pb-8">
        <View className="aspect-[4/3] overflow-hidden rounded-[1.375rem] bg-muted">
          {FINISHES.map((item) => (
            <FinishImage
              key={item.name}
              finish={item}
              active={item.name === finish}
            />
          ))}
        </View>
        <Text className="mt-1 text-xs font-semibold text-orange-500">New</Text>
        <Text role="heading" className="-mt-2.5 text-3xl font-semibold">
          Buy Phone Pro
        </Text>
        <Text className="text-lg font-semibold">
          Model.{" "}
          <Text className="text-lg font-semibold text-muted-foreground">
            Which one is right for you?
          </Text>
        </Text>
        <View
          accessibilityRole="radiogroup"
          accessibilityLabel="Model"
          className="gap-2"
        >
          {MODELS.map((item) => {
            const checked = item.name === model
            return (
              <Pressable
                key={item.name}
                role="radio"
                accessibilityState={{ checked }}
                onPress={() => {
                  setModel(item.name)
                  setAdded(false)
                }}
                className={cn(
                  CHOICE_CLASS,
                  choiceBorder(checked),
                  "flex-row items-center justify-between",
                  checked ? "px-3.75 py-3.25" : "px-4 py-3.5"
                )}
              >
                <Text className="flex-1 text-base font-semibold">
                  {item.name}
                </Text>
                <Text className="text-sm text-muted-foreground">
                  From {money(item.price, 0)}
                </Text>
              </Pressable>
            )
          })}
        </View>
        <Text className="text-lg font-semibold">
          Finish.{" "}
          <Text className="text-lg font-semibold text-muted-foreground">
            {finish}
          </Text>
        </Text>
        <ToggleGroup
          value={[finish]}
          onValueChange={(value) => {
            if (value[0]) setFinish(value[0])
          }}
          accessibilityLabel="Finish"
          spacing={2}
        >
          {FINISHES.map((item) => (
            <ToggleGroupItem
              key={item.name}
              value={item.name}
              accessibilityLabel={item.name}
              className={(state) =>
                cn(
                  "size-10 min-w-0 rounded-full border-2 bg-transparent p-0 active:bg-transparent",
                  state.pressed ? "border-primary" : "border-transparent"
                )
              }
            >
              <View
                className={cn(
                  "size-7.5 rounded-full border border-foreground/15",
                  item.swatch
                )}
              />
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
        <Text className="text-lg font-semibold">Storage.</Text>
        <View
          accessibilityRole="radiogroup"
          accessibilityLabel="Storage"
          className="flex-row gap-2"
        >
          {STORAGE.map((item) => {
            const checked = item.size === size
            return (
              <Pressable
                key={item.size}
                role="radio"
                accessibilityState={{ checked }}
                onPress={() => {
                  setSize(item.size)
                  setAdded(false)
                }}
                className={cn(
                  CHOICE_CLASS,
                  choiceBorder(checked),
                  "flex-1 items-center gap-0.5",
                  checked ? "px-1.25 py-2.75" : "px-1.5 py-3"
                )}
              >
                <Text numberOfLines={1} className="text-base font-semibold">
                  {item.size}
                </Text>
                <Text
                  numberOfLines={1}
                  className="text-xs text-muted-foreground"
                >
                  {item.extra ? `+${money(item.extra, 0)}` : "Included"}
                </Text>
              </Pressable>
            )
          })}
        </View>
      </ScrollView>
      <BottomBar separator>
        <BottomBarContent className="flex-row items-center gap-3">
          <View accessibilityLiveRegion="polite" className="flex-1">
            <Text className="text-2xl font-semibold tabular-nums">
              {money(price)}
            </Text>
            <Text className="text-xs text-muted-foreground">
              or {money(price / 12)}/mo. for 12 mo.
            </Text>
          </View>
          <Button
            size="lg"
            onPress={() => {
              onAddToBag?.({ model, finish, storage: size, price })
              setAdded(true)
            }}
          >
            <Text>{added ? "Added to Bag" : "Add to Bag"}</Text>
          </Button>
        </BottomBarContent>
      </BottomBar>
    </Screen>
  )
}

export { BagConfigure }
```

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

public struct BagConfigure: View {
  struct Model: Identifiable {
    let name: String
    let price: Double

    var id: String { name }
  }

  struct Finish: Identifiable {
    let name: String
    let color: Color
    let image: URL?
    let alt: String

    var id: String { name }
  }

  struct Storage: Identifiable {
    let size: String
    let extra: Double

    var id: String { size }
  }

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

  static var models: [Model] {
    [
      Model(name: "Phone Pro", price: 1099),
      Model(name: "Phone Pro Max", price: 1199),
    ]
  }

  static var finishes: [Finish] {
    [
      Finish(name: "Cosmic Orange", color: Palette.orange500, image: photo("1714972384975-fbd4f19b1bb3"), alt: "An orange phone on a warm gradient"),
      Finish(name: "Deep Blue", color: Palette.indigo500, image: photo("1714578187196-29775454aa39"), alt: "A phone floating among blue spheres"),
      Finish(name: "Silver", color: Palette.zinc500, image: photo("1664883410931-2edffc8be5e6"), alt: "A silver phone on a light grey background"),
    ]
  }

  static var storage: [Storage] {
    [
      Storage(size: "256 GB", extra: 0),
      Storage(size: "512 GB", extra: 200),
      Storage(size: "1 TB", extra: 400),
    ]
  }

  private var onAddToBag: ((String, String, String, Double) -> Void)?
  @State private var model: String
  @State private var finish: String
  @State private var size: String
  @State private var added: Bool
  @Environment(\.locale) private var locale
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(onAddToBag: ((_ model: String, _ finish: String, _ storage: String, _ price: Double) -> Void)? = nil) {
    self.init(model: "Phone Pro")
    self.onAddToBag = onAddToBag
  }

  init(model: String, finish: String = "Cosmic Orange", size: String = "256 GB", added: Bool = false) {
    _model = State(initialValue: model)
    _finish = State(initialValue: finish)
    _size = State(initialValue: size)
    _added = State(initialValue: added)
  }

  private var price: Double {
    (Self.models.first { $0.name == model }?.price ?? 0) + (Self.storage.first { $0.size == size }?.extra ?? 0)
  }

  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(alignment: .leading, spacing: 16) {
          gallery
          VStack(alignment: .leading, spacing: 16) {
            VStack(alignment: .leading, spacing: 6) {
              Text("New")
                .scaledFont(.xs, weight: .semibold)
                .foregroundStyle(Color.adaptive(light: Palette.amber600, dark: Palette.amber400))
              Text("Buy Phone Pro")
                .scaledTextStyle(.xl3, weight: .semibold)
                .accessibilityAddTraits(.isHeader)
            }
            heading("Model.", detail: Text("Which one is right for you?"))
            VStack(spacing: 8) {
              ForEach(Self.models) { item in
                BagConfigureCard(selected: model == item.name) {
                  select { model = item.name }
                } label: {
                  HStack {
                    Text(verbatim: item.name)
                      .scaledFont(.base, weight: .semibold)
                      .frame(maxWidth: .infinity, alignment: .leading)
                    Text("From \(money(item.price, digits: 0))")
                      .scaledFont(.sm)
                      .foregroundStyle(EsuissColor.mutedForeground)
                  }
                  .padding(.horizontal, 16)
                  .padding(.vertical, 14)
                }
              }
            }
            .accessibilityElement(children: .contain)
            .accessibilityLabel(Text("Model"))
            heading("Finish.", detail: Text(LocalizedStringKey(finish)))
            HStack(spacing: 14) {
              ForEach(Self.finishes) { item in
                BagConfigureSwatch(finish: item, selected: finish == item.name) {
                  finish = item.name
                }
              }
            }
            .padding(.leading, 4)
            .accessibilityElement(children: .contain)
            .accessibilityLabel(Text("Finish"))
            heading("Storage.", detail: nil)
            HStack(spacing: 8) {
              ForEach(Self.storage) { item in
                BagConfigureCard(selected: size == item.size) {
                  select { size = item.size }
                } label: {
                  VStack(spacing: 2) {
                    Text(verbatim: item.size)
                      .scaledFont(.base, weight: .semibold)
                    Group {
                      if item.extra > 0 {
                        Text(verbatim: "+\(money(item.extra, digits: 0))")
                      } else {
                        Text("Included")
                      }
                    }
                    .scaledFont(.xs)
                    .foregroundStyle(EsuissColor.mutedForeground)
                  }
                  .lineLimit(1)
                  .frame(maxWidth: .infinity)
                  .padding(.horizontal, 6)
                  .padding(.vertical, 12)
                }
              }
            }
            .accessibilityElement(children: .contain)
            .accessibilityLabel(Text("Storage"))
          }
          .padding(.horizontal, 20)
        }
        .padding(.bottom, 24)
      }
      BottomBar {
        BottomBarContent {
          HStack(spacing: 12) {
            VStack(alignment: .leading, spacing: 0) {
              Text(verbatim: money(price))
                .scaledFont(.xl, weight: .semibold)
                .monospacedDigit()
                .contentTransition(.numericText())
              Text("or \(money(price / 12))/mo. for 12 mo.")
                .scaledFont(.xs)
                .foregroundStyle(EsuissColor.mutedForeground)
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .accessibilityElement(children: .combine)
            Button(added ? "Added to Bag" : "Add to Bag") {
              onAddToBag?(model, finish, size, price)
              added = true
            }
            .buttonStyle(.variant(.default, size: .lg))
          }
        }
      }
    }
    .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: price)
  }

  private func select(_ change: () -> Void) {
    change()
    added = false
  }

  private func heading(_ title: LocalizedStringKey, detail: Text?) -> some View {
    Group {
      if let detail {
        Text("\(Text(title)) \(detail.foregroundStyle(EsuissColor.mutedForeground))")
      } else {
        Text(title)
      }
    }
    .scaledFont(.lg, weight: .semibold)
    .padding(.top, 4)
  }

  private var gallery: some View {
    EsuissColor.muted
      .aspectRatio(4 / 3, contentMode: .fit)
      .overlay {
        ForEach(Self.finishes) { item in
          Color.clear.overlay {
            AsyncImage(url: item.image) { phase in
              if let image = phase.image {
                image.resizable().scaledToFill()
              }
            }
          }
          .scaleEffect(item.name == finish ? 1 : 1.04)
          .opacity(item.name == finish ? 1 : 0)
          .animation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.9), value: finish)
          .accessibilityHidden(item.name != finish)
          .accessibilityLabel(Text(LocalizedStringKey(item.alt)))
        }
      }
      .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
      .padding(.horizontal, 20)
      .padding(.top, 20)
  }
}

private struct BagConfigureCard<Label: View>: View {
  let selected: Bool
  let action: () -> Void
  let label: Label

  init(selected: Bool, action: @escaping () -> Void, @ViewBuilder label: () -> Label) {
    self.selected = selected
    self.action = action
    self.label = label()
  }

  var body: some View {
    let shape = RoundedRectangle(cornerRadius: 14, style: .continuous)
    Button(action: action) {
      label
        .foregroundStyle(EsuissColor.foreground)
        .frame(maxWidth: .infinity)
        .contentShape(shape)
    }
    .buttonStyle(BagConfigureCardStyle(selected: selected))
    .accessibilityAddTraits(selected ? .isSelected : [])
    .hapticFeedback(.selection, trigger: selected) { _, now in now }
  }
}

private struct BagConfigureCardStyle: ButtonStyle {
  let selected: Bool

  func makeBody(configuration: Configuration) -> some View {
    let shape = RoundedRectangle(cornerRadius: 14, style: .continuous)
    configuration.label
      .opacity(configuration.isPressed ? 0.8 : 1)
      .overlay {
        shape.strokeBorder(selected ? EsuissColor.primary : EsuissColor.input, lineWidth: selected ? 2 : 1)
      }
      .animation(.easeOut(duration: 0.2), value: selected)
  }
}

private struct BagConfigureSwatch: View {
  let finish: BagConfigure.Finish
  let selected: Bool
  let action: () -> Void

  var body: some View {
    Button(action: action) {
      Circle()
        .fill(finish.color)
        .overlay {
          Circle().strokeBorder(EsuissColor.foreground.opacity(0.15), lineWidth: 1)
        }
        .frame(width: 30, height: 30)
        .padding(3)
        .overlay {
          if selected {
            Circle().strokeBorder(EsuissColor.primary, lineWidth: 2)
          }
        }
        .frame(width: 44, height: 44)
        .contentShape(Circle())
    }
    .buttonStyle(.plain)
    .padding(.horizontal, -4)
    .accessibilityLabel(Text(LocalizedStringKey(finish.name)))
    .accessibilityAddTraits(selected ? .isSelected : [])
    .hapticFeedback(.selection, trigger: selected) { _, now in now }
  }
}
```

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

import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
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.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.aspectRatio
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.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.LaunchedEffect
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.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
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.withStyle
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Light
import com.adamglin.phosphoricons.light.DeviceMobile
import com.esuiss.ui.components.BottomBar
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 val ConfigureEasing = CubicBezierEasing(0.32f, 0.72f, 0f, 1f)

private class ConfigureModel(val name: String, val price: Double)

private class ConfigureFinish(val name: String, val color: (Colors) -> Color, val alt: String)

private class ConfigureStorage(val size: String, val extra: Double)

private val ConfigureModels = listOf(
    ConfigureModel("Phone Pro", 1099.0),
    ConfigureModel("Phone Pro Max", 1199.0),
)

private val ConfigureFinishes = listOf(
    ConfigureFinish("Cosmic Orange", { Palette.orange500 }, "An orange phone on a warm gradient"),
    ConfigureFinish("Deep Blue", { Palette.indigo500 }, "A phone floating among blue spheres"),
    ConfigureFinish("Silver", { Palette.zinc500 }, "A silver phone on a light grey background"),
)

private val ConfigureStorageOptions = listOf(
    ConfigureStorage("256 GB", 0.0),
    ConfigureStorage("512 GB", 200.0),
    ConfigureStorage("1 TB", 400.0),
)

private fun configureMoney(value: Double, digits: Int = 2): String =
    NumberFormat.getCurrencyInstance(Locale.US).apply {
        currency = Currency.getInstance("USD")
        minimumFractionDigits = digits
        maximumFractionDigits = digits
    }.format(value)

@Composable
fun BagConfigure(
    modifier: Modifier = Modifier,
    onAddToBag: (model: String, finish: String, storage: String, price: Double) -> Unit = { _, _, _, _ -> },
) {
    val colors = EsuissTheme.colors
    var model by rememberSaveable { mutableStateOf(ConfigureModels.first().name) }
    var finish by rememberSaveable { mutableStateOf(ConfigureFinishes.first().name) }
    var size by rememberSaveable { mutableStateOf(ConfigureStorageOptions.first().size) }
    var added by rememberSaveable { mutableStateOf(false) }
    val price = (ConfigureModels.firstOrNull { it.name == model }?.price ?: 0.0) +
        (ConfigureStorageOptions.firstOrNull { it.size == size }?.extra ?: 0.0)
    val current = ConfigureFinishes.firstOrNull { it.name == finish } ?: ConfigureFinishes.first()

    Screen(modifier) {
        Column(Modifier.fillMaxSize()) {
            Column(
                Modifier
                    .weight(1f)
                    .verticalScroll(rememberScrollState())
                    .padding(horizontal = 20.dp, vertical = 20.dp),
                verticalArrangement = Arrangement.spacedBy(16.dp),
            ) {
                ConfigureHero(current)
                Column {
                    BasicText("New", style = Typography.xs.copy(color = if (colors.isDark) Palette.amber400 else Palette.amber600, fontWeight = FontWeight.SemiBold))
                    BasicText(
                        "Buy Phone Pro",
                        modifier = Modifier.semantics { heading() },
                        style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                    )
                }
                BasicText(
                    buildAnnotatedString {
                        append("Model. ")
                        withStyle(SpanStyle(color = colors.mutedForeground)) { append("Which one is right for you?") }
                    },
                    style = Typography.lg.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                )
                Column(
                    Modifier
                        .selectableGroup()
                        .semantics { contentDescription = "Model" },
                    verticalArrangement = Arrangement.spacedBy(8.dp),
                ) {
                    ConfigureModels.forEach { item ->
                        ConfigureCard(
                            selected = item.name == model,
                            onSelect = {
                                model = item.name
                                added = false
                            },
                        ) {
                            Row(
                                modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
                                verticalAlignment = Alignment.CenterVertically,
                            ) {
                                BasicText(
                                    item.name,
                                    modifier = Modifier.weight(1f),
                                    style = Typography.base.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                                )
                                BasicText("From ${configureMoney(item.price, 0)}", style = Typography.sm.copy(color = colors.mutedForeground))
                            }
                        }
                    }
                }
                BasicText(
                    buildAnnotatedString {
                        append("Finish. ")
                        withStyle(SpanStyle(color = colors.mutedForeground)) { append(finish) }
                    },
                    style = Typography.lg.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                )
                Row(
                    Modifier
                        .selectableGroup()
                        .semantics { contentDescription = "Finish" },
                    horizontalArrangement = Arrangement.spacedBy(6.dp),
                ) {
                    ConfigureFinishes.forEach { item ->
                        ConfigureSwatch(item, selected = item.name == finish) { finish = item.name }
                    }
                }
                BasicText("Storage.", style = Typography.lg.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold))
                Row(
                    Modifier
                        .selectableGroup()
                        .semantics { contentDescription = "Storage" },
                    horizontalArrangement = Arrangement.spacedBy(8.dp),
                ) {
                    ConfigureStorageOptions.forEach { item ->
                        ConfigureCard(
                            selected = item.size == size,
                            onSelect = {
                                size = item.size
                                added = false
                            },
                            modifier = Modifier.weight(1f),
                        ) {
                            Column(
                                modifier = Modifier
                                    .fillMaxWidth()
                                    .padding(horizontal = 6.dp, vertical = 12.dp),
                                horizontalAlignment = Alignment.CenterHorizontally,
                                verticalArrangement = Arrangement.spacedBy(2.dp),
                            ) {
                                BasicText(
                                    item.size,
                                    style = Typography.base.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                                    maxLines = 1,
                                )
                                BasicText(
                                    if (item.extra > 0) "+${configureMoney(item.extra, 0)}" else "Included",
                                    style = Typography.xs.copy(color = colors.mutedForeground),
                                    maxLines = 1,
                                )
                            }
                        }
                    }
                }
            }
            BottomBar(separator = true) {
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(horizontal = 16.dp, vertical = 12.dp),
                    horizontalArrangement = Arrangement.spacedBy(12.dp),
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    Column(
                        Modifier
                            .weight(1f)
                            .semantics(mergeDescendants = true) { liveRegion = LiveRegionMode.Polite },
                    ) {
                        BasicText(
                            configureMoney(price),
                            style = Typography.xl.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum"),
                        )
                        BasicText(
                            "or ${configureMoney(price / 12)}/mo. for 12 mo.",
                            style = Typography.xs.copy(color = colors.mutedForeground),
                        )
                    }
                    Button(
                        if (added) "Added to Bag" else "Add to Bag",
                        {
                            onAddToBag(model, finish, size, price)
                            added = true
                        },
                        size = ButtonSize.Lg,
                    )
                }
            }
        }
    }
}

@Composable
private fun ConfigureHero(finish: ConfigureFinish) {
    val colors = EsuissTheme.colors
    val tint by animateColorAsState(finish.color(colors), tween(600), label = "finish")
    val settle = remember { Animatable(1f) }
    LaunchedEffect(finish.name) {
        settle.snapTo(1.04f)
        settle.animateTo(1f, tween(900, easing = ConfigureEasing))
    }
    Box(
        Modifier
            .fillMaxWidth()
            .aspectRatio(4f / 3f)
            .background(colors.muted, RoundedCornerShape(22.dp))
            .clearAndSetSemantics { contentDescription = finish.alt },
        contentAlignment = Alignment.Center,
    ) {
        Icon(
            PhosphorIcons.Light.DeviceMobile,
            contentDescription = null,
            modifier = Modifier.graphicsLayer {
                scaleX = settle.value
                scaleY = settle.value
            },
            size = 140.dp,
            tint = tint,
        )
    }
}

@Composable
private fun ConfigureCard(
    selected: Boolean,
    onSelect: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {
    val colors = EsuissTheme.colors
    val haptics = rememberHaptics()
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val shape = RoundedCornerShape(14.dp)
    val border by animateColorAsState(if (selected) colors.primary else colors.input, tween(200), label = "border")
    Box(
        modifier = modifier
            .fillMaxWidth()
            .alpha(if (pressed) 0.8f else 1f)
            .border(if (selected) 2.dp else 1.dp, border, shape)
            .selectable(
                selected = selected,
                interactionSource = interaction,
                indication = null,
                role = Role.RadioButton,
                onClick = {
                    if (!selected) haptics.selection()
                    onSelect()
                },
            ),
    ) {
        content()
    }
}

@Composable
private fun ConfigureSwatch(finish: ConfigureFinish, selected: Boolean, onSelect: () -> Unit) {
    val colors = EsuissTheme.colors
    val haptics = rememberHaptics()
    val ring by animateColorAsState(if (selected) colors.primary else Color.Transparent, tween(200), label = "ring")
    Box(
        Modifier
            .size(44.dp)
            .selectable(
                selected = selected,
                interactionSource = remember { MutableInteractionSource() },
                indication = null,
                role = Role.RadioButton,
                onClick = {
                    if (!selected) haptics.selection()
                    onSelect()
                },
            )
            .semantics { contentDescription = finish.name },
        contentAlignment = Alignment.Center,
    ) {
        Box(
            Modifier
                .size(38.dp)
                .border(2.dp, ring, CircleShape)
                .padding(4.dp)
                .background(finish.color(colors), CircleShape)
                .border(1.dp, colors.foreground.copy(alpha = 0.15f), CircleShape),
        )
    }
}
```

Composes `Screen`, `ToggleGroup` color swatches, `Button`, `Text` and `BottomBar`. Model and storage are radio-style options, the finish swatches cross-fade the product photo, and the bottom bar shows the price and a monthly amount that update as you choose. Add to Bag switches its label to Added to Bag.

## Installation

**React Native**

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

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

**SwiftUI**

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

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add bag-configure --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), [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 react-native-reanimated 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), [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/bag-configure.tsx`:

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

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn react-native-reanimated 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), [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/bag-configure.tsx`:

```tsx title="nativewind/components/blocks/bag-configure.tsx"
import { cn } from "cn"
import * as React from "react"
import { Image, Pressable, ScrollView, View } from "react-native"
import {
  Easing,
  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 { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

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

type Model = { name: string; price: number }

type Finish = { name: string; swatch: string; image: string; alt: string }

type StorageOption = { size: string; extra: number }

const MODELS: [Model, ...Model[]] = [
  { name: "Phone Pro", price: 1099 },
  { name: "Phone Pro Max", price: 1199 },
]

const FINISHES: [Finish, ...Finish[]] = [
  {
    name: "Cosmic Orange",
    swatch: "bg-orange-500",
    image: photo("1714972384975-fbd4f19b1bb3"),
    alt: "An orange phone on a warm gradient",
  },
  {
    name: "Deep Blue",
    swatch: "bg-indigo-500",
    image: photo("1714578187196-29775454aa39"),
    alt: "A phone floating among blue spheres",
  },
  {
    name: "Silver",
    swatch: "bg-zinc-500",
    image: photo("1664883410931-2edffc8be5e6"),
    alt: "A silver phone on a light grey background",
  },
]

const STORAGE: [StorageOption, ...StorageOption[]] = [
  { size: "256 GB", extra: 0 },
  { size: "512 GB", extra: 200 },
  { size: "1 TB", extra: 400 },
]

const EASING = Easing.bezier(0.32, 0.72, 0, 1)

const THOUSANDS = /\B(?=(\d{3})+(?!\d))/g

function 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 = "0", fraction] = value.toFixed(digits).split(".")
    return `$${whole.replace(THOUSANDS, ",")}${fraction ? `.${fraction}` : ""}`
  }
}

const CHOICE_CLASS = "rounded-[0.875rem] active:opacity-80"

const choiceBorder = (checked: boolean) =>
  checked ? "border-2 border-primary" : "border border-input"

function FinishImage({ finish, active }: { finish: Finish; active: boolean }) {
  const progress = useSharedValue(active ? 1 : 0)

  React.useEffect(() => {
    progress.value = withTiming(active ? 1 : 0, {
      duration: 700,
      easing: EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [active, progress])

  const animatedStyle = useAnimatedStyle(() => ({
    opacity: progress.value,
    transform: [{ scale: 1.04 - progress.value * 0.04 }],
  }))

  return (
    <AnimatedView
      pointerEvents="none"
      accessibilityElementsHidden={!active}
      importantForAccessibility={active ? "auto" : "no-hide-descendants"}
      className="absolute inset-0"
      style={animatedStyle}
    >
      <Image
        source={{ uri: finish.image }}
        resizeMode="cover"
        accessible
        accessibilityLabel={finish.alt}
        accessibilityIgnoresInvertColors
        className="size-full"
      />
    </AnimatedView>
  )
}

function BagConfigure({
  onAddToBag,
}: {
  onAddToBag?: (configuration: {
    model: string
    finish: string
    storage: string
    price: number
  }) => void
}) {
  const [model, setModel] = React.useState(MODELS[0].name)
  const [finish, setFinish] = React.useState(FINISHES[0].name)
  const [size, setSize] = React.useState(STORAGE[0].size)
  const [added, setAdded] = React.useState(false)
  const price =
    (MODELS.find((item) => item.name === model)?.price ?? 0) +
    (STORAGE.find((item) => item.size === size)?.extra ?? 0)

  return (
    <Screen edges={["top", "right", "left"]}>
      <ScrollView contentContainerClassName="gap-4 px-5 pt-5 pb-8">
        <View className="aspect-[4/3] overflow-hidden rounded-[1.375rem] bg-muted">
          {FINISHES.map((item) => (
            <FinishImage
              key={item.name}
              finish={item}
              active={item.name === finish}
            />
          ))}
        </View>
        <Text className="mt-1 text-xs font-semibold text-orange-500">New</Text>
        <Text role="heading" className="-mt-2.5 text-3xl font-semibold">
          Buy Phone Pro
        </Text>
        <Text className="text-lg font-semibold">
          Model.{" "}
          <Text className="text-lg font-semibold text-muted-foreground">
            Which one is right for you?
          </Text>
        </Text>
        <View
          accessibilityRole="radiogroup"
          accessibilityLabel="Model"
          className="gap-2"
        >
          {MODELS.map((item) => {
            const checked = item.name === model
            return (
              <Pressable
                key={item.name}
                role="radio"
                accessibilityState={{ checked }}
                onPress={() => {
                  setModel(item.name)
                  setAdded(false)
                }}
                className={cn(
                  CHOICE_CLASS,
                  choiceBorder(checked),
                  "flex-row items-center justify-between",
                  checked ? "px-[0.9375rem] py-[0.8125rem]" : "px-4 py-3.5"
                )}
              >
                <Text className="flex-1 text-base font-semibold">
                  {item.name}
                </Text>
                <Text className="text-sm text-muted-foreground">
                  From {money(item.price, 0)}
                </Text>
              </Pressable>
            )
          })}
        </View>
        <Text className="text-lg font-semibold">
          Finish.{" "}
          <Text className="text-lg font-semibold text-muted-foreground">
            {finish}
          </Text>
        </Text>
        <ToggleGroup
          value={[finish]}
          onValueChange={(value) => {
            if (value[0]) setFinish(value[0])
          }}
          accessibilityLabel="Finish"
          spacing={2}
        >
          {FINISHES.map((item) => (
            <ToggleGroupItem
              key={item.name}
              value={item.name}
              accessibilityLabel={item.name}
              className={(state) =>
                cn(
                  "size-10 min-w-0 rounded-full border-2 bg-transparent p-0 active:bg-transparent",
                  state.pressed ? "border-primary" : "border-transparent"
                )
              }
            >
              <View
                className={cn(
                  "size-[1.875rem] rounded-full border border-foreground-15",
                  item.swatch
                )}
              />
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
        <Text className="text-lg font-semibold">Storage.</Text>
        <View
          accessibilityRole="radiogroup"
          accessibilityLabel="Storage"
          className="flex-row gap-2"
        >
          {STORAGE.map((item) => {
            const checked = item.size === size
            return (
              <Pressable
                key={item.size}
                role="radio"
                accessibilityState={{ checked }}
                onPress={() => {
                  setSize(item.size)
                  setAdded(false)
                }}
                className={cn(
                  CHOICE_CLASS,
                  choiceBorder(checked),
                  "flex-1 items-center gap-0.5",
                  checked ? "px-[0.3125rem] py-[0.6875rem]" : "px-1.5 py-3"
                )}
              >
                <Text numberOfLines={1} className="text-base font-semibold">
                  {item.size}
                </Text>
                <Text
                  numberOfLines={1}
                  className="text-xs text-muted-foreground"
                >
                  {item.extra ? `+${money(item.extra, 0)}` : "Included"}
                </Text>
              </Pressable>
            )
          })}
        </View>
      </ScrollView>
      <BottomBar separator>
        <BottomBarContent className="flex-row items-center gap-3">
          <View accessibilityLiveRegion="polite" className="flex-1">
            <Text className="text-2xl font-semibold tabular-nums">
              {money(price)}
            </Text>
            <Text className="text-xs text-muted-foreground">
              or {money(price / 12)}/mo. for 12 mo.
            </Text>
          </View>
          <Button
            size="lg"
            onPress={() => {
              onAddToBag?.({ model, finish, storage: size, price })
              setAdded(true)
            }}
          >
            <Text>{added ? "Added to Bag" : "Add to Bag"}</Text>
          </Button>
        </BottomBarContent>
      </BottomBar>
    </Screen>
  )
}

export { BagConfigure }
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install react-native-reanimated 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), [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/bag-configure.tsx`:

```tsx title="stylesheet/components/blocks/bag-configure.tsx"
import * as React from "react"
import { Image, Pressable, ScrollView, StyleSheet, View } from "react-native"
import {
  Easing,
  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 { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import {
  fontSize,
  palette,
  radius,
  type ThemeColors,
  useTheme,
} from "@/components/ui/theme"

import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Tone = keyof ThemeColors | keyof typeof palette

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

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

type Model = { name: string; price: number }

type Finish = {
  name: string
  swatch: Tone
  image: string
  alt: string
}

type StorageOption = { size: string; extra: number }

const MODELS: [Model, ...Model[]] = [
  { name: "Phone Pro", price: 1099 },
  { name: "Phone Pro Max", price: 1199 },
]

const FINISHES: [Finish, ...Finish[]] = [
  {
    name: "Cosmic Orange",
    swatch: "orange500",
    image: photo("1714972384975-fbd4f19b1bb3"),
    alt: "An orange phone on a warm gradient",
  },
  {
    name: "Deep Blue",
    swatch: "indigo500",
    image: photo("1714578187196-29775454aa39"),
    alt: "A phone floating among blue spheres",
  },
  {
    name: "Silver",
    swatch: "zinc500",
    image: photo("1664883410931-2edffc8be5e6"),
    alt: "A silver phone on a light grey background",
  },
]

const STORAGE: [StorageOption, ...StorageOption[]] = [
  { size: "256 GB", extra: 0 },
  { size: "512 GB", extra: 200 },
  { size: "1 TB", extra: 400 },
]

const EASING = Easing.bezier(0.32, 0.72, 0, 1)

const THOUSANDS = /\B(?=(\d{3})+(?!\d))/g

function 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 = "0", fraction] = value.toFixed(digits).split(".")
    return `$${whole.replace(THOUSANDS, ",")}${fraction ? `.${fraction}` : ""}`
  }
}

function withAlpha(color: string, alpha: number) {
  if (!color.startsWith("#") || color.length !== 7) return color
  const r = Number.parseInt(color.slice(1, 3), 16)
  const g = Number.parseInt(color.slice(3, 5), 16)
  const b = Number.parseInt(color.slice(5, 7), 16)
  return `rgba(${r}, ${g}, ${b}, ${alpha})`
}

const styles = StyleSheet.create({
  content: {
    gap: 16,
    paddingHorizontal: 20,
    paddingTop: 20,
    paddingBottom: 32,
  },
  hero: { aspectRatio: 4 / 3, overflow: "hidden", borderRadius: 22 },
  layer: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0 },
  image: { width: "100%", height: "100%" },
  kicker: { ...fontSize.xs, marginTop: 4, fontWeight: "600" },
  title: { ...fontSize["3xl"], marginTop: -10, fontWeight: "600" },
  label: { ...fontSize.lg, fontWeight: "600" },
  models: { gap: 8 },
  choice: { borderRadius: 14 },
  idle: { borderWidth: 1 },
  checked: { borderWidth: 2 },
  model: {
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "space-between",
  },
  modelIdle: { paddingHorizontal: 16, paddingVertical: 14 },
  modelChecked: { paddingHorizontal: 15, paddingVertical: 13 },
  modelName: { ...fontSize.base, flex: 1, fontWeight: "600" },
  swatchItem: {
    width: 40,
    height: 40,
    minWidth: 0,
    borderRadius: radius.full,
    borderWidth: 2,
    paddingHorizontal: 0,
    backgroundColor: "transparent",
  },
  swatch: {
    width: 30,
    height: 30,
    borderRadius: radius.full,
    borderWidth: 1,
  },
  storage: { flexDirection: "row", gap: 8 },
  size: { flex: 1, alignItems: "center", gap: 2 },
  sizeIdle: { paddingHorizontal: 6, paddingVertical: 12 },
  sizeChecked: { paddingHorizontal: 5, paddingVertical: 11 },
  sizeName: { ...fontSize.base, fontWeight: "600" },
  bar: { flexDirection: "row", alignItems: "center", gap: 12 },
  grow: { flex: 1 },
  price: {
    ...fontSize["2xl"],
    fontWeight: "600",
    fontVariant: ["tabular-nums"],
  },
})

function FinishImage({ finish, active }: { finish: Finish; active: boolean }) {
  const progress = useSharedValue(active ? 1 : 0)

  React.useEffect(() => {
    progress.value = withTiming(active ? 1 : 0, {
      duration: 700,
      easing: EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [active, progress])

  const animatedStyle = useAnimatedStyle(() => ({
    opacity: progress.value,
    transform: [{ scale: 1.04 - progress.value * 0.04 }],
  }))

  return (
    <AnimatedView
      pointerEvents="none"
      accessibilityElementsHidden={!active}
      importantForAccessibility={active ? "auto" : "no-hide-descendants"}
      style={[styles.layer, animatedStyle]}
    >
      <Image
        source={{ uri: finish.image }}
        resizeMode="cover"
        accessible
        accessibilityLabel={finish.alt}
        accessibilityIgnoresInvertColors
        style={styles.image}
      />
    </AnimatedView>
  )
}

function BagConfigure({
  onAddToBag,
}: {
  onAddToBag?: (configuration: {
    model: string
    finish: string
    storage: string
    price: number
  }) => void
}) {
  const { colors } = useTheme()
  const [model, setModel] = React.useState(MODELS[0].name)
  const [finish, setFinish] = React.useState(FINISHES[0].name)
  const [size, setSize] = React.useState(STORAGE[0].size)
  const [added, setAdded] = React.useState(false)
  const price =
    (MODELS.find((item) => item.name === model)?.price ?? 0) +
    (STORAGE.find((item) => item.size === size)?.extra ?? 0)

  const choice = (checked: boolean, pressed: boolean) => [
    styles.choice,
    checked ? styles.checked : styles.idle,
    { borderColor: checked ? colors.primary : colors.input },
    pressed && { opacity: 0.8 },
  ]

  return (
    <Screen edges={["top", "right", "left"]}>
      <ScrollView contentContainerStyle={styles.content}>
        <View style={[styles.hero, { backgroundColor: colors.muted }]}>
          {FINISHES.map((item) => (
            <FinishImage
              key={item.name}
              finish={item}
              active={item.name === finish}
            />
          ))}
        </View>
        <Text style={[styles.kicker, { color: palette.orange500 }]}>New</Text>
        <Text role="heading" style={styles.title}>
          Buy Phone Pro
        </Text>
        <Text style={styles.label}>
          Model.{" "}
          <Text style={[styles.label, { color: colors.mutedForeground }]}>
            Which one is right for you?
          </Text>
        </Text>
        <View
          accessibilityRole="radiogroup"
          accessibilityLabel="Model"
          style={styles.models}
        >
          {MODELS.map((item) => {
            const checked = item.name === model
            return (
              <Pressable
                key={item.name}
                role="radio"
                accessibilityState={{ checked }}
                onPress={() => {
                  setModel(item.name)
                  setAdded(false)
                }}
                style={({ pressed }) => [
                  ...choice(checked, pressed),
                  styles.model,
                  checked ? styles.modelChecked : styles.modelIdle,
                ]}
              >
                <Text style={styles.modelName}>{item.name}</Text>
                <Text style={[fontSize.sm, { color: colors.mutedForeground }]}>
                  From {money(item.price, 0)}
                </Text>
              </Pressable>
            )
          })}
        </View>
        <Text style={styles.label}>
          Finish.{" "}
          <Text style={[styles.label, { color: colors.mutedForeground }]}>
            {finish}
          </Text>
        </Text>
        <ToggleGroup
          value={[finish]}
          onValueChange={(value) => {
            if (value[0]) setFinish(value[0])
          }}
          accessibilityLabel="Finish"
          spacing={2}
        >
          {FINISHES.map((item) => (
            <ToggleGroupItem
              key={item.name}
              value={item.name}
              accessibilityLabel={item.name}
              style={(state) => [
                styles.swatchItem,
                {
                  borderColor: state.pressed ? colors.primary : "transparent",
                },
              ]}
            >
              <View
                style={[
                  styles.swatch,
                  {
                    backgroundColor: tone(colors, item.swatch),
                    borderColor: withAlpha(colors.foreground, 0.15),
                  },
                ]}
              />
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
        <Text style={styles.label}>Storage.</Text>
        <View
          accessibilityRole="radiogroup"
          accessibilityLabel="Storage"
          style={styles.storage}
        >
          {STORAGE.map((item) => {
            const checked = item.size === size
            return (
              <Pressable
                key={item.size}
                role="radio"
                accessibilityState={{ checked }}
                onPress={() => {
                  setSize(item.size)
                  setAdded(false)
                }}
                style={({ pressed }) => [
                  ...choice(checked, pressed),
                  styles.size,
                  checked ? styles.sizeChecked : styles.sizeIdle,
                ]}
              >
                <Text numberOfLines={1} style={styles.sizeName}>
                  {item.size}
                </Text>
                <Text
                  numberOfLines={1}
                  style={[fontSize.xs, { color: colors.mutedForeground }]}
                >
                  {item.extra ? `+${money(item.extra, 0)}` : "Included"}
                </Text>
              </Pressable>
            )
          })}
        </View>
      </ScrollView>
      <BottomBar separator>
        <BottomBarContent style={styles.bar}>
          <View accessibilityLiveRegion="polite" style={styles.grow}>
            <Text style={styles.price}>{money(price)}</Text>
            <Text style={[fontSize.xs, { color: colors.mutedForeground }]}>
              or {money(price / 12)}/mo. for 12 mo.
            </Text>
          </View>
          <Button
            size="lg"
            onPress={() => {
              onAddToBag?.({ model, finish, storage: size, price })
              setAdded(true)
            }}
          >
            <Text>{added ? "Added to Bag" : "Add to Bag"}</Text>
          </Button>
        </BottomBarContent>
      </BottomBar>
    </Screen>
  )
}

export { BagConfigure }
```

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/BagConfigure.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/BagConfigure.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 { BagConfigure } from "@/components/blocks/bag-configure"
```

```tsx
<BagConfigure />
```

Replace `MODELS`, `FINISHES` and `STORAGE` with your catalog data. Pass `onAddToBag` to receive the selected model, finish, storage and price.

