# Grid Accessories

A filterable accessories grid with category chips and add-to-bag buttons that update a bag counter.

Grid Accessories 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/grid-accessories

**React**

**Example**

```tsx title="components/blocks/grid-accessories.tsx"
"use client"

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

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

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

const filters = ["All", "Audio", "Cases", "Bands", "Pad"]

const accessories = [
  {
    name: "Pods Studio Headphones",
    category: "Audio",
    price: "$549",
    image: photo("1599669454699-248893623440"),
  },
  {
    name: "Phone Pro Clear Case",
    category: "Cases",
    price: "$49",
    image: photo("1703676311066-34caf2cbee33"),
  },
  {
    name: "Sport Band",
    category: "Bands",
    price: "$49",
    image: photo("1624096104992-9b4fa3a279dd"),
  },
  {
    name: "Keyboard Folio for Pad",
    category: "Pad",
    price: "$299",
    image: photo("1740637977676-c8040b41dc7a"),
  },
  {
    name: "Pods Sage Headphones",
    category: "Audio",
    price: "$349",
    image: photo("1693621947585-7b7d94149af4"),
  },
  {
    name: "Phone Silicone Case",
    category: "Cases",
    price: "$49",
    image: photo("1714578187196-29775454aa39"),
  },
  {
    name: "Solo Loop",
    category: "Bands",
    price: "$49",
    image: photo("1660844817855-3ecc7ef21f12"),
  },
  {
    name: "Pencil Pro",
    category: "Pad",
    price: "$129",
    image: photo("1625864667534-aa5208d45a87"),
  },
]

export function GridAccessories() {
  const [filter, setFilter] = React.useState("All")
  const [bag, setBag] = React.useState<string[]>([])
  const items = accessories.filter(
    (item) => filter === "All" || item.category === filter
  )

  return (
    <section className="flex flex-col gap-6 bg-background px-5 py-9 md:px-10">
      <div className="flex items-start gap-4">
        <div className="flex flex-1 flex-col gap-0.5">
          <h2 className="text-4xl font-semibold tracking-tight">Accessories</h2>
          <p className="text-base text-muted-foreground">
            Designed for every suiss device.
          </p>
        </div>
        <span
          role="status"
          aria-label={`${bag.length} items in bag`}
          className="relative mt-2 flex"
        >
          <HandbagIcon className="size-5.5" />
          {bag.length > 0 && (
            <span className="absolute -end-2 -bottom-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-foreground px-1 text-xs font-semibold text-background tabular-nums transition-[scale] duration-300 ease-[cubic-bezier(0.3,1.25,0.5,1)] starting:scale-0">
              {bag.length}
            </span>
          )}
        </span>
      </div>
      <ToggleGroup
        value={[filter]}
        onValueChange={(value: string[]) => {
          if (value[0]) setFilter(value[0])
        }}
        aria-label="Category"
        className="flex-wrap"
      >
        {filters.map((item) => (
          <ToggleGroupItem
            key={item}
            value={item}
            className="h-8.5 bg-secondary px-4 transition-[background-color,color] duration-200 hover:bg-secondary/80 aria-pressed:bg-foreground aria-pressed:text-background aria-pressed:hover:bg-foreground aria-pressed:hover:text-background"
          >
            {item}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
      <ul className="grid grid-cols-2 gap-x-4 gap-y-6 md:grid-cols-[repeat(auto-fill,minmax(12.5rem,1fr))] md:gap-5">
        {items.map((item) => {
          const inBag = bag.includes(item.name)
          return (
            <li
              key={`${filter}-${item.name}`}
              className="flex flex-col transition-[opacity,translate] duration-1000 ease-[cubic-bezier(0.32,0.72,0,1)] starting:translate-y-2.5 starting:opacity-0 motion-reduce:transition-none"
            >
              <img
                src={item.image}
                alt=""
                className="aspect-square w-full rounded-2xl bg-muted object-cover"
              />
              <span className="mt-3 min-h-10 text-base leading-tight font-semibold">
                {item.name}
              </span>
              <div className="mt-1 flex items-center gap-2">
                <span className="flex-1 text-base">{item.price}</span>
                <Button
                  size="icon-sm"
                  data-in-bag={inBag ? "" : undefined}
                  aria-label={
                    inBag
                      ? `Remove ${item.name} from bag`
                      : `Add ${item.name} to bag`
                  }
                  aria-pressed={inBag}
                  onClick={() =>
                    setBag((current) =>
                      inBag
                        ? current.filter((name) => name !== item.name)
                        : [...current, item.name]
                    )
                  }
                  className="relative h-7.5 w-7.5 gap-1 overflow-hidden text-xs transition-[width,padding,background-color] duration-300 ease-[cubic-bezier(0.3,1.25,0.5,1)] after:absolute after:-inset-1.75 after:content-[''] data-in-bag:w-21 data-in-bag:bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_40%)] data-in-bag:px-3 data-in-bag:hover:bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_50%)] motion-reduce:transition-none dark:data-in-bag:bg-[color-mix(in_oklab,var(--color-green-500),black_45%)] dark:data-in-bag:hover:bg-[color-mix(in_oklab,var(--color-green-500),black_55%)]"
                >
                  {inBag ? (
                    <CheckIcon className="size-3.75" />
                  ) : (
                    <PlusIcon className="size-3.75" />
                  )}
                  {inBag && <span aria-hidden>In bag</span>}
                </Button>
              </div>
            </li>
          )
        })}
      </ul>
    </section>
  )
}
```

A product grid with category filter chips and an add button on each card that turns into an In bag pill. A bag icon in the header shows the running count. Cards sit two per row on mobile and fill the width with auto-sized columns on desktop. Use it for accessory, add-on or catalog listings where shoppers add items straight from the grid.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/grid-accessories
```

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 grid-accessories
```

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

3. Copy the source into `components/blocks/grid-accessories.tsx`:

```tsx title="components/blocks/grid-accessories.tsx"
"use client"

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

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

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

const filters = ["All", "Audio", "Cases", "Bands", "Pad"]

const accessories = [
  {
    name: "Pods Studio Headphones",
    category: "Audio",
    price: "$549",
    image: photo("1599669454699-248893623440"),
  },
  {
    name: "Phone Pro Clear Case",
    category: "Cases",
    price: "$49",
    image: photo("1703676311066-34caf2cbee33"),
  },
  {
    name: "Sport Band",
    category: "Bands",
    price: "$49",
    image: photo("1624096104992-9b4fa3a279dd"),
  },
  {
    name: "Keyboard Folio for Pad",
    category: "Pad",
    price: "$299",
    image: photo("1740637977676-c8040b41dc7a"),
  },
  {
    name: "Pods Sage Headphones",
    category: "Audio",
    price: "$349",
    image: photo("1693621947585-7b7d94149af4"),
  },
  {
    name: "Phone Silicone Case",
    category: "Cases",
    price: "$49",
    image: photo("1714578187196-29775454aa39"),
  },
  {
    name: "Solo Loop",
    category: "Bands",
    price: "$49",
    image: photo("1660844817855-3ecc7ef21f12"),
  },
  {
    name: "Pencil Pro",
    category: "Pad",
    price: "$129",
    image: photo("1625864667534-aa5208d45a87"),
  },
]

export function GridAccessories() {
  const [filter, setFilter] = React.useState("All")
  const [bag, setBag] = React.useState<string[]>([])
  const items = accessories.filter(
    (item) => filter === "All" || item.category === filter
  )

  return (
    <section className="flex flex-col gap-6 bg-background px-5 py-9 md:px-10">
      <div className="flex items-start gap-4">
        <div className="flex flex-1 flex-col gap-0.5">
          <h2 className="text-4xl font-semibold tracking-tight">Accessories</h2>
          <p className="text-base text-muted-foreground">
            Designed for every suiss device.
          </p>
        </div>
        <span
          role="status"
          aria-label={`${bag.length} items in bag`}
          className="relative mt-2 flex"
        >
          <HandbagIcon className="size-5.5" />
          {bag.length > 0 && (
            <span className="absolute -end-2 -bottom-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-foreground px-1 text-xs font-semibold text-background tabular-nums transition-[scale] duration-300 ease-[cubic-bezier(0.3,1.25,0.5,1)] starting:scale-0">
              {bag.length}
            </span>
          )}
        </span>
      </div>
      <ToggleGroup
        value={[filter]}
        onValueChange={(value: string[]) => {
          if (value[0]) setFilter(value[0])
        }}
        aria-label="Category"
        className="flex-wrap"
      >
        {filters.map((item) => (
          <ToggleGroupItem
            key={item}
            value={item}
            className="h-8.5 bg-secondary px-4 transition-[background-color,color] duration-200 hover:bg-secondary/80 aria-pressed:bg-foreground aria-pressed:text-background aria-pressed:hover:bg-foreground aria-pressed:hover:text-background"
          >
            {item}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
      <ul className="grid grid-cols-2 gap-x-4 gap-y-6 md:grid-cols-[repeat(auto-fill,minmax(12.5rem,1fr))] md:gap-5">
        {items.map((item) => {
          const inBag = bag.includes(item.name)
          return (
            <li
              key={`${filter}-${item.name}`}
              className="flex flex-col transition-[opacity,translate] duration-1000 ease-[cubic-bezier(0.32,0.72,0,1)] starting:translate-y-2.5 starting:opacity-0 motion-reduce:transition-none"
            >
              <img
                src={item.image}
                alt=""
                className="aspect-square w-full rounded-2xl bg-muted object-cover"
              />
              <span className="mt-3 min-h-10 text-base leading-tight font-semibold">
                {item.name}
              </span>
              <div className="mt-1 flex items-center gap-2">
                <span className="flex-1 text-base">{item.price}</span>
                <Button
                  size="icon-sm"
                  data-in-bag={inBag ? "" : undefined}
                  aria-label={
                    inBag
                      ? `Remove ${item.name} from bag`
                      : `Add ${item.name} to bag`
                  }
                  aria-pressed={inBag}
                  onClick={() =>
                    setBag((current) =>
                      inBag
                        ? current.filter((name) => name !== item.name)
                        : [...current, item.name]
                    )
                  }
                  className="relative h-7.5 w-7.5 gap-1 overflow-hidden text-xs transition-[width,padding,background-color] duration-300 ease-[cubic-bezier(0.3,1.25,0.5,1)] after:absolute after:-inset-1.75 after:content-[''] data-in-bag:w-21 data-in-bag:bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_40%)] data-in-bag:px-3 data-in-bag:hover:bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_50%)] motion-reduce:transition-none dark:data-in-bag:bg-[color-mix(in_oklab,var(--color-green-500),black_45%)] dark:data-in-bag:hover:bg-[color-mix(in_oklab,var(--color-green-500),black_55%)]"
                >
                  {inBag ? (
                    <CheckIcon className="size-3.75" />
                  ) : (
                    <PlusIcon className="size-3.75" />
                  )}
                  {inBag && <span aria-hidden>In bag</span>}
                </Button>
              </div>
            </li>
          )
        })}
      </ul>
    </section>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { GridAccessories } from "@/components/blocks/grid-accessories"
```

```tsx
<GridAccessories />
```

Edit `filters` for the chips and `accessories` for the products; each product has a name, a category matching one of the filters, a formatted price and an image. Replace the local bag toggle with your cart logic.

## Notes

The bag count is announced, and each add button names its product and exposes its pressed state. Product photos are Unsplash placeholders.

**React Native, SwiftUI, Jetpack Compose**

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

```tsx title="uniwind/components/blocks/grid-accessories.tsx"
import { cn } from "cn"
import { CheckIcon, HandbagIcon, PlusIcon } from "phosphor-react-native"
import * as React from "react"
import { Image, ScrollView, View } from "react-native"
import {
  Easing,
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
  ZoomIn,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Chip, ChipGroup } from "@/components/ui/chip"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

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

const FILTERS = ["All", "Audio", "Cases", "Bands", "Pad"]

const ACCESSORIES = [
  {
    name: "Pods Studio Headphones",
    category: "Audio",
    price: "$549",
    image: photo("1599669454699-248893623440"),
  },
  {
    name: "Phone Pro Clear Case",
    category: "Cases",
    price: "$49",
    image: photo("1703676311066-34caf2cbee33"),
  },
  {
    name: "Sport Band",
    category: "Bands",
    price: "$49",
    image: photo("1624096104992-9b4fa3a279dd"),
  },
  {
    name: "Keyboard Folio for Pad",
    category: "Pad",
    price: "$299",
    image: photo("1740637977676-c8040b41dc7a"),
  },
  {
    name: "Pods Sage Headphones",
    category: "Audio",
    price: "$349",
    image: photo("1693621947585-7b7d94149af4"),
  },
  {
    name: "Phone Silicone Case",
    category: "Cases",
    price: "$49",
    image: photo("1714578187196-29775454aa39"),
  },
  {
    name: "Solo Loop",
    category: "Bands",
    price: "$49",
    image: photo("1660844817855-3ecc7ef21f12"),
  },
  {
    name: "Pencil Pro",
    category: "Pad",
    price: "$129",
    image: photo("1625864667534-aa5208d45a87"),
  },
]

type Accessory = (typeof ACCESSORIES)[number]

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

function BagButton({
  name,
  inBag,
  onPress,
}: {
  name: string
  inBag: boolean
  onPress: () => void
}) {
  const width = useSharedValue(inBag ? 84 : 30)

  React.useEffect(() => {
    width.value = withTiming(inBag ? 84 : 30, {
      duration: 300,
      easing: EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [inBag, width])

  const animatedStyle = useAnimatedStyle(() => ({ width: width.value }))

  return (
    <AnimatedView
      className="h-7.5 overflow-hidden rounded-full"
      style={animatedStyle}
    >
      <Button
        size="icon-sm"
        hitSlop={7}
        accessibilityState={{ selected: inBag }}
        accessibilityLabel={
          inBag ? `Remove ${name} from bag` : `Add ${name} to bag`
        }
        onPress={onPress}
        className={cn(
          "h-7.5 w-full flex-row gap-1 px-0",
          inBag && "bg-green-500 active:opacity-80"
        )}
      >
        <Icon
          as={inBag ? CheckIcon : PlusIcon}
          weight="bold"
          className="size-3.75"
        />
        {inBag ? (
          <Text numberOfLines={1} className="text-xs">
            In bag
          </Text>
        ) : null}
      </Button>
    </AnimatedView>
  )
}

function AccessoryCard({
  item,
  inBag,
  onToggle,
}: {
  item: Accessory
  inBag: boolean
  onToggle: () => void
}) {
  return (
    <AnimatedView
      entering={FadeInDown.duration(700).reduceMotion(ReduceMotion.System)}
      className="flex-1"
    >
      <Image
        source={{ uri: item.image }}
        resizeMode="cover"
        accessibilityIgnoresInvertColors
        className="aspect-square w-full rounded-2xl bg-muted"
      />
      <Text numberOfLines={2} className="mt-3 min-h-11 text-base font-semibold">
        {item.name}
      </Text>
      <View className="mt-1 flex-row items-center gap-2">
        <Text className="flex-1 text-base">{item.price}</Text>
        <BagButton name={item.name} inBag={inBag} onPress={onToggle} />
      </View>
    </AnimatedView>
  )
}

function GridAccessories({
  onAddToBag,
  onRemoveFromBag,
  onFilterChange,
}: {
  onAddToBag?: (name: string) => void
  onRemoveFromBag?: (name: string) => void
  onFilterChange?: (category: string) => void
}) {
  const [filter, setFilter] = React.useState("All")
  const [bag, setBag] = React.useState<string[]>([])
  const items = ACCESSORIES.filter(
    (item) => filter === "All" || item.category === filter
  )
  const rows: Accessory[][] = []
  for (let index = 0; index < items.length; index += 2) {
    rows.push(items.slice(index, index + 2))
  }

  const toggle = (name: string) => {
    const inBag = bag.includes(name)
    if (inBag) onRemoveFromBag?.(name)
    else onAddToBag?.(name)
    setBag((current) =>
      inBag ? current.filter((item) => item !== name) : [...current, name]
    )
  }

  return (
    <Screen>
      <ScrollView contentContainerClassName="gap-6 py-9">
        <View className="flex-row items-start gap-4 px-5">
          <View className="flex-1 gap-0.5">
            <Text role="heading" className="text-3xl font-semibold">
              Accessories
            </Text>
            <Text className="text-base text-muted-foreground">
              Designed for every suiss device.
            </Text>
          </View>
          <View
            accessible
            accessibilityLabel={`${bag.length} items in bag`}
            accessibilityLiveRegion="polite"
            className="mt-2"
          >
            <Icon as={HandbagIcon} className="size-5.5" />
            {bag.length > 0 ? (
              <AnimatedView
                key={bag.length}
                entering={ZoomIn.duration(300).reduceMotion(
                  ReduceMotion.System
                )}
                className="absolute -end-2 -bottom-1 h-4 min-w-4 items-center justify-center rounded-full bg-foreground px-1"
              >
                <Text className="text-xs font-semibold text-background tabular-nums">
                  {bag.length}
                </Text>
              </AnimatedView>
            ) : null}
          </View>
        </View>
        <ChipGroup
          scrollable
          value={[filter]}
          onValueChange={(value) => {
            if (!value[0]) return
            setFilter(value[0])
            onFilterChange?.(value[0])
          }}
          accessibilityLabel="Category"
          className="px-5"
        >
          {FILTERS.map((item) => (
            <Chip key={item} value={item} className="h-8.5 px-4">
              {item}
            </Chip>
          ))}
        </ChipGroup>
        <View className="gap-6 px-5">
          {rows.map((row) => (
            <View
              key={row.map((item) => item.name).join("|")}
              className="flex-row gap-4"
            >
              {row.map((item) => (
                <AccessoryCard
                  key={`${filter}-${item.name}`}
                  item={item}
                  inBag={bag.includes(item.name)}
                  onToggle={() => toggle(item.name)}
                />
              ))}
              {row.length === 1 ? <View className="flex-1" /> : null}
            </View>
          ))}
        </View>
      </ScrollView>
    </Screen>
  )
}

export { GridAccessories }
```

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

public struct GridAccessories: View {
  struct Accessory: Identifiable {
    let name: String
    let category: String
    let price: String
    let image: URL?

    var id: String { name }
  }

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

  static let filters = ["All", "Audio", "Cases", "Bands", "Pad"]

  static var accessories: [Accessory] {
    [
      Accessory(name: "Pods Studio Headphones", category: "Audio", price: "$549", image: photo("1599669454699-248893623440")),
      Accessory(name: "Phone Pro Clear Case", category: "Cases", price: "$49", image: photo("1703676311066-34caf2cbee33")),
      Accessory(name: "Sport Band", category: "Bands", price: "$49", image: photo("1624096104992-9b4fa3a279dd")),
      Accessory(name: "Keyboard Folio for Pad", category: "Pad", price: "$299", image: photo("1740637977676-c8040b41dc7a")),
      Accessory(name: "Pods Sage Headphones", category: "Audio", price: "$349", image: photo("1693621947585-7b7d94149af4")),
      Accessory(name: "Phone Silicone Case", category: "Cases", price: "$49", image: photo("1714578187196-29775454aa39")),
      Accessory(name: "Solo Loop", category: "Bands", price: "$49", image: photo("1660844817855-3ecc7ef21f12")),
      Accessory(name: "Pencil Pro", category: "Pad", price: "$129", image: photo("1625864667534-aa5208d45a87")),
    ]
  }

  private var onAddToBag: ((String) -> Void)?
  private var onRemoveFromBag: ((String) -> Void)?
  private var onOpenBag: (() -> Void)?
  private var onFilterChange: ((String) -> Void)?
  @State private var filter: String?
  @State private var bag: [String]
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    onAddToBag: ((_ accessory: String) -> Void)? = nil,
    onRemoveFromBag: ((_ accessory: String) -> Void)? = nil,
    onOpenBag: (() -> Void)? = nil,
    onFilterChange: ((_ category: String) -> Void)? = nil
  ) {
    self.init(filter: "All")
    self.onAddToBag = onAddToBag
    self.onRemoveFromBag = onRemoveFromBag
    self.onOpenBag = onOpenBag
    self.onFilterChange = onFilterChange
  }

  init(filter: String, bag: [String] = []) {
    _filter = State(initialValue: filter)
    _bag = State(initialValue: bag)
  }

  private var items: [Accessory] {
    let current = filter ?? "All"
    return Self.accessories.filter { current == "All" || $0.category == current }
  }

  public var body: some View {
    Screen {
      HeaderScrollView {
        HeaderLargeTitle("Accessories", subtitle: "Designed for every suiss device.")
        VStack(alignment: .leading, spacing: 20) {
          ChipGroup(selection: filterSelection, scrollable: true) {
            ForEach(Self.filters, id: \.self) { item in
              Chip(LocalizedStringKey(item), value: item)
            }
          }
          .contentMargins(.horizontal, 16, for: .scrollContent)
          .accessibilityLabel(Text("Category"))
          LazyVGrid(columns: [GridItem(.flexible(), spacing: 16), GridItem(.flexible(), spacing: 16)], alignment: .leading, spacing: 24) {
            ForEach(items) { item in
              GridAccessoriesCell(item: item, inBag: bag.contains(item.name)) {
                toggle(item.name)
              }
              .transition(.opacity.combined(with: .offset(y: 10)))
            }
          }
          .padding(.horizontal, 16)
        }
        .padding(.top, 4)
        .padding(.bottom, 24)
      } header: {
        Header {
          HeaderTitle("Accessories")
        } trailing: {
          Button {
            onOpenBag?()
          } label: {
            Ph.handbag.regular
              .frame(width: 22, height: 22)
              .foregroundStyle(EsuissColor.foreground)
              .overlay(alignment: .bottomTrailing) {
                if !bag.isEmpty {
                  Text(verbatim: "\(bag.count)")
                    .font(.system(size: TextStyle.xs.size, weight: .semibold))
                    .monospacedDigit()
                    .foregroundStyle(EsuissColor.background)
                    .padding(.horizontal, 4)
                    .frame(minWidth: 16, minHeight: 16)
                    .background(EsuissColor.foreground, in: Capsule())
                    .offset(x: 8, y: 4)
                    .transition(.scale)
                }
              }
              .frame(width: 44, height: 44)
              .contentShape(Rectangle())
          }
          .buttonStyle(.plain)
          .accessibilityLabel(Text("Bag"))
          .accessibilityValue(Text("\(bag.count) items in bag"))
        }
      }
    }
    .animation(reduceMotion ? nil : .spring(response: 0.3, dampingFraction: 0.6), value: bag)
    .animation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.6), value: filter)
  }

  private var filterSelection: Binding<String?> {
    Binding(
      get: { filter },
      set: { value in
        guard let value, value != filter else { return }
        filter = value
        onFilterChange?(value)
      }
    )
  }

  private func toggle(_ name: String) {
    if let index = bag.firstIndex(of: name) {
      bag.remove(at: index)
      onRemoveFromBag?(name)
    } else {
      bag.append(name)
      onAddToBag?(name)
    }
  }
}

private struct GridAccessoriesCell: View {
  let item: GridAccessories.Accessory
  let inBag: Bool
  let action: () -> Void

  var body: some View {
    VStack(alignment: .leading, spacing: 0) {
      EsuissColor.muted
        .overlay {
          AsyncImage(url: item.image) { phase in
            if let image = phase.image {
              image.resizable().scaledToFill()
            }
          }
        }
        .aspectRatio(1, contentMode: .fit)
        .clipShape(RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
        .accessibilityHidden(true)
      Text(verbatim: item.name)
        .scaledFont(.base, weight: .semibold)
        .lineLimit(2, reservesSpace: true)
        .fixedSize(horizontal: false, vertical: true)
        .padding(.top, 12)
      HStack(spacing: 8) {
        Text(verbatim: item.price)
          .scaledFont(.base)
          .frame(maxWidth: .infinity, alignment: .leading)
        Button(action: action) {
          HStack(spacing: 4) {
            (inBag ? Ph.check.bold : Ph.plus.bold)
              .frame(width: 15, height: 15)
            if inBag {
              Text("In bag")
                .scaledFont(.xs, weight: .medium)
                .fixedSize()
                .accessibilityHidden(true)
            }
          }
          .foregroundStyle(EsuissColor.primaryForeground)
          .padding(.horizontal, inBag ? 12 : 0)
          .frame(minWidth: 30, minHeight: 30)
          .background(inBag ? Palette.green500 : EsuissColor.primary, in: Capsule())
          .frame(minHeight: 44)
          .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .accessibilityLabel(inBag ? Text("Remove \(item.name) from bag") : Text("Add \(item.name) to bag"))
        .accessibilityAddTraits(inBag ? .isSelected : [])
        .hapticFeedback(.impact(weight: .light), trigger: inBag)
      }
      .padding(.top, 2)
    }
  }
}
```

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

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
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.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Light
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.bold.Check
import com.adamglin.phosphoricons.bold.Plus
import com.adamglin.phosphoricons.light.DeviceMobile
import com.adamglin.phosphoricons.light.DeviceMobileCamera
import com.adamglin.phosphoricons.light.Headphones
import com.adamglin.phosphoricons.light.Keyboard
import com.adamglin.phosphoricons.light.PencilSimple
import com.adamglin.phosphoricons.light.Watch
import com.adamglin.phosphoricons.regular.Handbag
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.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Typography

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

private val AccessoryEnterEasing = CubicBezierEasing(0.32f, 0.72f, 0f, 1f)

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

private val AccessoryFilters = listOf("All", "Audio", "Cases", "Bands", "Pad")

private val Accessories = listOf(
    Accessory("Pods Studio Headphones", "Audio", "$549", PhosphorIcons.Light.Headphones),
    Accessory("Phone Pro Clear Case", "Cases", "$49", PhosphorIcons.Light.DeviceMobileCamera),
    Accessory("Sport Band", "Bands", "$49", PhosphorIcons.Light.Watch),
    Accessory("Keyboard Folio for Pad", "Pad", "$299", PhosphorIcons.Light.Keyboard),
    Accessory("Pods Sage Headphones", "Audio", "$349", PhosphorIcons.Light.Headphones),
    Accessory("Phone Silicone Case", "Cases", "$49", PhosphorIcons.Light.DeviceMobile),
    Accessory("Solo Loop", "Bands", "$49", PhosphorIcons.Light.Watch),
    Accessory("Pencil Pro", "Pad", "$129", PhosphorIcons.Light.PencilSimple),
)

@Composable
fun GridAccessories(
    modifier: Modifier = Modifier,
    onAddToBag: (item: String) -> Unit = {},
    onRemoveFromBag: (item: String) -> Unit = {},
    onFilterChange: (category: String) -> Unit = {},
) {
    val colors = EsuissTheme.colors
    var filter by rememberSaveable { mutableStateOf("All") }
    val bag = remember { mutableStateListOf<String>() }
    val items = Accessories.filter { filter == "All" || it.category == filter }

    Screen(modifier) {
        Column(
            Modifier
                .fillMaxSize()
                .verticalScroll(rememberScrollState())
                .padding(vertical = 36.dp),
            verticalArrangement = Arrangement.spacedBy(24.dp),
        ) {
            Row(
                modifier = Modifier.padding(horizontal = 20.dp),
                horizontalArrangement = Arrangement.spacedBy(16.dp),
            ) {
                Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
                    BasicText(
                        "Accessories",
                        modifier = Modifier.semantics { heading() },
                        style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                    )
                    BasicText("Designed for every suiss device.", style = Typography.base.copy(color = colors.mutedForeground))
                }
                AccessoryBag(bag.size)
            }
            Row(
                Modifier
                    .fillMaxWidth()
                    .horizontalScroll(rememberScrollState())
                    .padding(horizontal = 20.dp),
            ) {
                ToggleGroup(
                    value = filter,
                    onValueChange = { value ->
                        if (value != null && value != filter) {
                            filter = value
                            onFilterChange(value)
                        }
                    },
                    modifier = Modifier.semantics { contentDescription = "Category" },
                ) {
                    AccessoryFilters.forEach { ToggleGroupItem(it, it) }
                }
            }
            Column(
                Modifier.padding(horizontal = 20.dp),
                verticalArrangement = Arrangement.spacedBy(24.dp),
            ) {
                items.chunked(2).forEach { pair ->
                    Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
                        pair.forEach { item ->
                            key(filter, item.name) {
                                AccessoryTile(
                                    item = item,
                                    inBag = item.name in bag,
                                    modifier = Modifier.weight(1f),
                                ) {
                                    if (item.name in bag) {
                                        onRemoveFromBag(item.name)
                                        bag.remove(item.name)
                                    } else {
                                        onAddToBag(item.name)
                                        bag.add(item.name)
                                    }
                                }
                            }
                        }
                        if (pair.size == 1) Spacer(Modifier.weight(1f))
                    }
                }
            }
        }
    }
}

@Composable
private fun AccessoryBag(count: Int) {
    val colors = EsuissTheme.colors
    Box(
        Modifier
            .padding(top = 8.dp, end = 8.dp)
            .semantics {
                contentDescription = "$count items in bag"
                liveRegion = LiveRegionMode.Polite
            },
    ) {
        Icon(PhosphorIcons.Regular.Handbag, contentDescription = null, size = 22.dp, tint = colors.foreground)
        AnimatedVisibility(
            visible = count > 0,
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .offset(x = 8.dp, y = 4.dp),
            enter = scaleIn(tween(300, easing = AccessoryPopEasing)),
            exit = scaleOut(tween(150)),
        ) {
            Box(
                Modifier
                    .heightIn(min = 16.dp)
                    .background(colors.foreground, CircleShape)
                    .padding(horizontal = 5.dp),
                contentAlignment = Alignment.Center,
            ) {
                BasicText(
                    count.toString(),
                    style = Typography.xs.copy(color = colors.background, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum"),
                )
            }
        }
    }
}

@Composable
private fun AccessoryTile(item: Accessory, inBag: Boolean, modifier: Modifier, onToggle: () -> Unit) {
    val colors = EsuissTheme.colors
    val appear = remember { Animatable(0f) }
    LaunchedEffect(Unit) { appear.animateTo(1f, tween(1000, easing = AccessoryEnterEasing)) }
    Column(
        modifier.graphicsLayer {
            alpha = appear.value
            translationY = (1f - appear.value) * 10.dp.toPx()
        },
    ) {
        Box(
            Modifier
                .fillMaxWidth()
                .aspectRatio(1f)
                .background(colors.muted, RoundedCornerShape(16.dp))
                .clearAndSetSemantics {},
            contentAlignment = Alignment.Center,
        ) {
            Icon(item.icon, contentDescription = null, size = 64.dp, tint = colors.mutedForeground)
        }
        BasicText(
            item.name,
            modifier = Modifier.padding(top = 12.dp),
            style = Typography.base.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
            minLines = 2,
            maxLines = 2,
        )
        Row(
            modifier = Modifier.padding(top = 4.dp),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            BasicText(item.price, modifier = Modifier.weight(1f), style = Typography.base.copy(color = colors.foreground))
            AccessoryAddButton(item.name, inBag, onToggle)
        }
    }
}

@Composable
private fun AccessoryAddButton(name: String, inBag: Boolean, onToggle: () -> Unit) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val width by animateDpAsState(if (inBag) 84.dp else 30.dp, tween(300, easing = AccessoryPopEasing), label = "width")
    val fill by animateColorAsState(
        if (inBag) Palette.green500 else colors.primary,
        tween(300),
        label = "fill",
    )
    Box(
        Modifier
            .height(44.dp)
            .alpha(if (pressed) 0.8f else 1f)
            .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onToggle)
            .semantics {
                contentDescription = if (inBag) "Remove $name from bag" else "Add $name to bag"
                stateDescription = if (inBag) "In bag" else "Not in bag"
            },
        contentAlignment = Alignment.CenterEnd,
    ) {
        Row(
            Modifier
                .width(width)
                .height(30.dp)
                .clip(CircleShape)
                .background(fill, CircleShape),
            horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Icon(
                if (inBag) PhosphorIcons.Bold.Check else PhosphorIcons.Bold.Plus,
                contentDescription = null,
                size = 15.dp,
                tint = colors.primaryForeground,
            )
            AnimatedVisibility(visible = inBag, enter = fadeIn(tween(300))) {
                BasicText("In bag", style = Typography.xs.copy(color = colors.primaryForeground, fontWeight = FontWeight.Medium), maxLines = 1, softWrap = false)
            }
        }
    }
}
```

Composes `Screen`, `Chip` filters, `Button`, `Icon` and `Text`. Category chips filter the grid, and each card's plus button expands into an In bag state while a badge on the bag icon shows the item count.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/grid-accessories
```

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 grid-accessories
```

**SwiftUI**

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

**Jetpack Compose**

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

The CLI also adds what it builds on: [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/grid-accessories.tsx`:

- `uniwind/components/blocks/grid-accessories.tsx`: shown above

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/grid-accessories.tsx`:

```tsx title="nativewind/components/blocks/grid-accessories.tsx"
import { cn } from "cn"
import { CheckIcon, HandbagIcon, PlusIcon } from "phosphor-react-native"
import * as React from "react"
import { Image, ScrollView, View } from "react-native"
import {
  Easing,
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
  ZoomIn,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Chip, ChipGroup } from "@/components/ui/chip"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

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

const FILTERS = ["All", "Audio", "Cases", "Bands", "Pad"]

const ACCESSORIES = [
  {
    name: "Pods Studio Headphones",
    category: "Audio",
    price: "$549",
    image: photo("1599669454699-248893623440"),
  },
  {
    name: "Phone Pro Clear Case",
    category: "Cases",
    price: "$49",
    image: photo("1703676311066-34caf2cbee33"),
  },
  {
    name: "Sport Band",
    category: "Bands",
    price: "$49",
    image: photo("1624096104992-9b4fa3a279dd"),
  },
  {
    name: "Keyboard Folio for Pad",
    category: "Pad",
    price: "$299",
    image: photo("1740637977676-c8040b41dc7a"),
  },
  {
    name: "Pods Sage Headphones",
    category: "Audio",
    price: "$349",
    image: photo("1693621947585-7b7d94149af4"),
  },
  {
    name: "Phone Silicone Case",
    category: "Cases",
    price: "$49",
    image: photo("1714578187196-29775454aa39"),
  },
  {
    name: "Solo Loop",
    category: "Bands",
    price: "$49",
    image: photo("1660844817855-3ecc7ef21f12"),
  },
  {
    name: "Pencil Pro",
    category: "Pad",
    price: "$129",
    image: photo("1625864667534-aa5208d45a87"),
  },
]

type Accessory = (typeof ACCESSORIES)[number]

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

function BagButton({
  name,
  inBag,
  onPress,
}: {
  name: string
  inBag: boolean
  onPress: () => void
}) {
  const width = useSharedValue(inBag ? 84 : 30)

  React.useEffect(() => {
    width.value = withTiming(inBag ? 84 : 30, {
      duration: 300,
      easing: EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [inBag, width])

  const animatedStyle = useAnimatedStyle(() => ({ width: width.value }))

  return (
    <AnimatedView
      className="h-[1.875rem] overflow-hidden rounded-full"
      style={animatedStyle}
    >
      <Button
        size="icon-sm"
        hitSlop={7}
        accessibilityState={{ selected: inBag }}
        accessibilityLabel={
          inBag ? `Remove ${name} from bag` : `Add ${name} to bag`
        }
        onPress={onPress}
        className={cn(
          "h-[1.875rem] w-full flex-row gap-1 px-0",
          inBag && "bg-green-500 active:opacity-80"
        )}
      >
        <Icon
          as={inBag ? CheckIcon : PlusIcon}
          weight="bold"
          className="size-[0.9375rem]"
        />
        {inBag ? (
          <Text numberOfLines={1} className="text-xs">
            In bag
          </Text>
        ) : null}
      </Button>
    </AnimatedView>
  )
}

function AccessoryCard({
  item,
  inBag,
  onToggle,
}: {
  item: Accessory
  inBag: boolean
  onToggle: () => void
}) {
  return (
    <AnimatedView
      entering={FadeInDown.duration(700).reduceMotion(ReduceMotion.System)}
      className="flex-1"
    >
      <Image
        source={{ uri: item.image }}
        resizeMode="cover"
        accessibilityIgnoresInvertColors
        className="aspect-square w-full rounded-2xl bg-muted"
      />
      <Text numberOfLines={2} className="mt-3 min-h-11 text-base font-semibold">
        {item.name}
      </Text>
      <View className="mt-1 flex-row items-center gap-2">
        <Text className="flex-1 text-base">{item.price}</Text>
        <BagButton name={item.name} inBag={inBag} onPress={onToggle} />
      </View>
    </AnimatedView>
  )
}

function GridAccessories({
  onAddToBag,
  onRemoveFromBag,
  onFilterChange,
}: {
  onAddToBag?: (name: string) => void
  onRemoveFromBag?: (name: string) => void
  onFilterChange?: (category: string) => void
}) {
  const [filter, setFilter] = React.useState("All")
  const [bag, setBag] = React.useState<string[]>([])
  const items = ACCESSORIES.filter(
    (item) => filter === "All" || item.category === filter
  )
  const rows: Accessory[][] = []
  for (let index = 0; index < items.length; index += 2) {
    rows.push(items.slice(index, index + 2))
  }

  const toggle = (name: string) => {
    const inBag = bag.includes(name)
    if (inBag) onRemoveFromBag?.(name)
    else onAddToBag?.(name)
    setBag((current) =>
      inBag ? current.filter((item) => item !== name) : [...current, name]
    )
  }

  return (
    <Screen>
      <ScrollView contentContainerClassName="gap-6 py-9">
        <View className="flex-row items-start gap-4 px-5">
          <View className="flex-1 gap-0.5">
            <Text role="heading" className="text-3xl font-semibold">
              Accessories
            </Text>
            <Text className="text-base text-muted-foreground">
              Designed for every suiss device.
            </Text>
          </View>
          <View
            accessible
            accessibilityLabel={`${bag.length} items in bag`}
            accessibilityLiveRegion="polite"
            className="mt-2"
          >
            <Icon as={HandbagIcon} className="size-[1.375rem]" />
            {bag.length > 0 ? (
              <AnimatedView
                key={bag.length}
                entering={ZoomIn.duration(300).reduceMotion(
                  ReduceMotion.System
                )}
                className="absolute -end-2 -bottom-1 h-4 min-w-4 items-center justify-center rounded-full bg-foreground px-1"
              >
                <Text className="text-xs font-semibold text-background tabular-nums">
                  {bag.length}
                </Text>
              </AnimatedView>
            ) : null}
          </View>
        </View>
        <ChipGroup
          scrollable
          value={[filter]}
          onValueChange={(value) => {
            if (!value[0]) return
            setFilter(value[0])
            onFilterChange?.(value[0])
          }}
          accessibilityLabel="Category"
          className="px-5"
        >
          {FILTERS.map((item) => (
            <Chip key={item} value={item} className="h-[2.125rem] px-4">
              {item}
            </Chip>
          ))}
        </ChipGroup>
        <View className="gap-6 px-5">
          {rows.map((row) => (
            <View
              key={row.map((item) => item.name).join("|")}
              className="flex-row gap-4"
            >
              {row.map((item) => (
                <AccessoryCard
                  key={`${filter}-${item.name}`}
                  item={item}
                  inBag={bag.includes(item.name)}
                  onToggle={() => toggle(item.name)}
                />
              ))}
              {row.length === 1 ? <View className="flex-1" /> : null}
            </View>
          ))}
        </View>
      </ScrollView>
    </Screen>
  )
}

export { GridAccessories }
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/grid-accessories.tsx`:

```tsx title="stylesheet/components/blocks/grid-accessories.tsx"
import { CheckIcon, HandbagIcon, PlusIcon } from "phosphor-react-native"
import * as React from "react"
import { Image, ScrollView, StyleSheet, View } from "react-native"
import {
  Easing,
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
  ZoomIn,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Chip, ChipGroup } from "@/components/ui/chip"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import { fontSize, palette, radius, useTheme } from "@/components/ui/theme"

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

const FILTERS = ["All", "Audio", "Cases", "Bands", "Pad"]

const ACCESSORIES = [
  {
    name: "Pods Studio Headphones",
    category: "Audio",
    price: "$549",
    image: photo("1599669454699-248893623440"),
  },
  {
    name: "Phone Pro Clear Case",
    category: "Cases",
    price: "$49",
    image: photo("1703676311066-34caf2cbee33"),
  },
  {
    name: "Sport Band",
    category: "Bands",
    price: "$49",
    image: photo("1624096104992-9b4fa3a279dd"),
  },
  {
    name: "Keyboard Folio for Pad",
    category: "Pad",
    price: "$299",
    image: photo("1740637977676-c8040b41dc7a"),
  },
  {
    name: "Pods Sage Headphones",
    category: "Audio",
    price: "$349",
    image: photo("1693621947585-7b7d94149af4"),
  },
  {
    name: "Phone Silicone Case",
    category: "Cases",
    price: "$49",
    image: photo("1714578187196-29775454aa39"),
  },
  {
    name: "Solo Loop",
    category: "Bands",
    price: "$49",
    image: photo("1660844817855-3ecc7ef21f12"),
  },
  {
    name: "Pencil Pro",
    category: "Pad",
    price: "$129",
    image: photo("1625864667534-aa5208d45a87"),
  },
]

type Accessory = (typeof ACCESSORIES)[number]

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

const styles = StyleSheet.create({
  content: { gap: 24, paddingVertical: 36 },
  header: {
    flexDirection: "row",
    alignItems: "flex-start",
    gap: 16,
    paddingHorizontal: 20,
  },
  headerText: { flex: 1, gap: 2 },
  title: { ...fontSize["3xl"], fontWeight: "600" },
  subtitle: { ...fontSize.base },
  bag: { marginTop: 8 },
  badge: {
    position: "absolute",
    end: -8,
    bottom: -4,
    height: 16,
    minWidth: 16,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
    paddingHorizontal: 4,
  },
  badgeText: {
    ...fontSize.xs,
    fontWeight: "600",
    fontVariant: ["tabular-nums"],
  },
  filters: { paddingHorizontal: 20 },
  chip: { height: 34, paddingHorizontal: 16 },
  grid: { gap: 24, paddingHorizontal: 20 },
  row: { flexDirection: "row", gap: 16 },
  card: { flex: 1 },
  image: { aspectRatio: 1, width: "100%", borderRadius: radius["2xl"] },
  name: {
    ...fontSize.base,
    fontWeight: "600",
    marginTop: 12,
    minHeight: 44,
  },
  footer: {
    flexDirection: "row",
    alignItems: "center",
    gap: 8,
    marginTop: 4,
  },
  price: { ...fontSize.base, flex: 1 },
  bagButton: { height: 30, overflow: "hidden", borderRadius: radius.full },
  button: {
    height: 30,
    width: "100%",
    flexDirection: "row",
    gap: 4,
    paddingHorizontal: 0,
  },
  buttonText: { ...fontSize.xs },
})

function BagButton({
  name,
  inBag,
  onPress,
}: {
  name: string
  inBag: boolean
  onPress: () => void
}) {
  const width = useSharedValue(inBag ? 84 : 30)

  React.useEffect(() => {
    width.value = withTiming(inBag ? 84 : 30, {
      duration: 300,
      easing: EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [inBag, width])

  const animatedStyle = useAnimatedStyle(() => ({ width: width.value }))

  return (
    <AnimatedView style={[styles.bagButton, animatedStyle]}>
      <Button
        size="icon-sm"
        hitSlop={7}
        accessibilityState={{ selected: inBag }}
        accessibilityLabel={
          inBag ? `Remove ${name} from bag` : `Add ${name} to bag`
        }
        onPress={onPress}
        style={[styles.button, inBag && { backgroundColor: palette.green500 }]}
      >
        <Icon as={inBag ? CheckIcon : PlusIcon} weight="bold" size={15} />
        {inBag ? (
          <Text numberOfLines={1} style={styles.buttonText}>
            In bag
          </Text>
        ) : null}
      </Button>
    </AnimatedView>
  )
}

function AccessoryCard({
  item,
  inBag,
  onToggle,
}: {
  item: Accessory
  inBag: boolean
  onToggle: () => void
}) {
  const { colors } = useTheme()
  return (
    <AnimatedView
      entering={FadeInDown.duration(700).reduceMotion(ReduceMotion.System)}
      style={styles.card}
    >
      <Image
        source={{ uri: item.image }}
        resizeMode="cover"
        accessibilityIgnoresInvertColors
        style={[styles.image, { backgroundColor: colors.muted }]}
      />
      <Text numberOfLines={2} style={styles.name}>
        {item.name}
      </Text>
      <View style={styles.footer}>
        <Text style={styles.price}>{item.price}</Text>
        <BagButton name={item.name} inBag={inBag} onPress={onToggle} />
      </View>
    </AnimatedView>
  )
}

function GridAccessories({
  onAddToBag,
  onRemoveFromBag,
  onFilterChange,
}: {
  onAddToBag?: (name: string) => void
  onRemoveFromBag?: (name: string) => void
  onFilterChange?: (category: string) => void
}) {
  const { colors } = useTheme()
  const [filter, setFilter] = React.useState("All")
  const [bag, setBag] = React.useState<string[]>([])
  const items = ACCESSORIES.filter(
    (item) => filter === "All" || item.category === filter
  )
  const rows: Accessory[][] = []
  for (let index = 0; index < items.length; index += 2) {
    rows.push(items.slice(index, index + 2))
  }

  const toggle = (name: string) => {
    const inBag = bag.includes(name)
    if (inBag) onRemoveFromBag?.(name)
    else onAddToBag?.(name)
    setBag((current) =>
      inBag ? current.filter((item) => item !== name) : [...current, name]
    )
  }

  return (
    <Screen>
      <ScrollView contentContainerStyle={styles.content}>
        <View style={styles.header}>
          <View style={styles.headerText}>
            <Text role="heading" style={styles.title}>
              Accessories
            </Text>
            <Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
              Designed for every suiss device.
            </Text>
          </View>
          <View
            accessible
            accessibilityLabel={`${bag.length} items in bag`}
            accessibilityLiveRegion="polite"
            style={styles.bag}
          >
            <Icon as={HandbagIcon} size={22} />
            {bag.length > 0 ? (
              <AnimatedView
                key={bag.length}
                entering={ZoomIn.duration(300).reduceMotion(
                  ReduceMotion.System
                )}
                style={[styles.badge, { backgroundColor: colors.foreground }]}
              >
                <Text style={[styles.badgeText, { color: colors.background }]}>
                  {bag.length}
                </Text>
              </AnimatedView>
            ) : null}
          </View>
        </View>
        <ChipGroup
          scrollable
          value={[filter]}
          onValueChange={(value) => {
            if (!value[0]) return
            setFilter(value[0])
            onFilterChange?.(value[0])
          }}
          accessibilityLabel="Category"
          style={styles.filters}
        >
          {FILTERS.map((item) => (
            <Chip key={item} value={item} style={styles.chip}>
              {item}
            </Chip>
          ))}
        </ChipGroup>
        <View style={styles.grid}>
          {rows.map((row) => (
            <View
              key={row.map((item) => item.name).join("|")}
              style={styles.row}
            >
              {row.map((item) => (
                <AccessoryCard
                  key={`${filter}-${item.name}`}
                  item={item}
                  inBag={bag.includes(item.name)}
                  onToggle={() => toggle(item.name)}
                />
              ))}
              {row.length === 1 ? <View style={styles.card} /> : null}
            </View>
          ))}
        </View>
      </ScrollView>
    </Screen>
  )
}

export { GridAccessories }
```

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

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

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

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

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

- `swift/Components/Combobox.swift`: see [Combobox](https://ui.suiss.app/docs/components/combobox.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/Header.swift`: see [Header](https://ui.suiss.app/docs/components/header.md)

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

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

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

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

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/GridAccessories.kt`: shown above

- `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 { GridAccessories } from "@/components/blocks/grid-accessories"
```

```tsx
<GridAccessories />
```

Replace `ACCESSORIES` and `FILTERS` with your catalog. Pass `onAddToBag`, `onRemoveFromBag` and `onFilterChange` to sync with your cart.

