# Bag Configure

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

A shadcn/ui block from the [@esuiss](https://suiss.app/ui?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/esuiss/bag-configure).

- Page, with a live preview: https://shadcn.exchange/r/esuiss/bag-configure
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/esuiss/bag-configure-light-e28bd43593996a1d.png
- Registry item: https://suiss.app/ui/r/new-york-v4/bag-configure.json
- Categories: blocks, bag
- Listed: 2026-10-20, source last changed 2026-10-20

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/esuiss/bag-configure.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `@esuiss/button`, `@esuiss/label`, `@esuiss/radio-group`, `@esuiss/toggle-group`

## Files

### `bases/radix/components/blocks/bag-configure.tsx`

```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
          type="single"
          value={finish}
          onValueChange={(value: string) => {
            if (value) {
              setFinish(value)
              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) data-[state=on]:bg-(--swatch) data-[state=on]:ring-2 data-[state=on]:ring-primary data-[state=on]:ring-offset-2 data-[state=on]:ring-offset-background data-[state=on]: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>
  )
}
```
