# Grid Accessories

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

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/grid-accessories).

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

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/esuiss/grid-accessories.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/toggle-group`

## Files

### `bases/radix/components/blocks/grid-accessories.tsx`

```tsx
"use client"

import * as React from "react"

import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"
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"
        >
          <IconPlaceholder
            lucide="HandbagIcon"
            tabler="IconShoppingBag"
            hugeicons="ShoppingBag01Icon"
            phosphor="HandbagIcon"
            remixicon="RiHandbagLine"
            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
        type="single"
        value={filter}
        onValueChange={(value: string) => {
          if (value) setFilter(value)
        }}
        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 data-[state=on]:bg-foreground data-[state=on]:text-background data-[state=on]:hover:bg-foreground data-[state=on]: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 ? (
                    <IconPlaceholder
                      lucide="CheckIcon"
                      tabler="IconCheck"
                      hugeicons="Tick02Icon"
                      phosphor="CheckIcon"
                      remixicon="RiCheckLine"
                      className="size-3.75"
                    />
                  ) : (
                    <IconPlaceholder
                      lucide="PlusIcon"
                      tabler="IconPlus"
                      hugeicons="PlusSignIcon"
                      phosphor="PlusIcon"
                      remixicon="RiAddLine"
                      className="size-3.75"
                    />
                  )}
                  {inBag && <span aria-hidden>In bag</span>}
                </Button>
              </div>
            </li>
          )
        })}
      </ul>
    </section>
  )
}
```
