Skip to content
oks-ui

Commerce

Product grid

A storefront listing built for a real catalog, not a placeholder grid: each Card carries a colored cover, a category Chip, a star rating built from real Check-free SVG stars, a sale price with the original struck through when discounted, and both an Add To Cart button and a separate bookmark action.

Built withCardChipButton
product-grid.tsx

How it's built

1

The grid needs its own `@container` on a wrapper, not on the grid element itself — `@xl:grid-cols-2`/`@3xl:grid-cols-4` can't be conditioned by the same element that establishes the container (that's circular per the CSS Containment spec), so they'd silently never apply.

2

The "Top" ribbon is a real `Chip` positioned absolutely over the cover, not a CSS-only badge — it inherits the same color system as every other Chip on the site.

3

Star rating icons draw with `stroke="currentColor"` and take a `filled` boolean instead of a color prop — wrap them in a colored element (here, the warning-color text on the row) to theme the rating consistently.

4

Only plans with an `originalPrice` render the struck-through price — `null` means "not on sale", not "$0 off".

Source

One file · copy & paste
product-grid.tsx
import { Button } from "oks-ui/button";
import { Card, CardBody, CardFooter } from "oks-ui/card";
import { Chip } from "oks-ui/chip";
import { CartIcon } from "../../showcase/icons";

// A five-point star — filled or outline depending on `filled`, not a
// reproduction of any specific icon set's artwork.
function StarIcon({
  size = 14,
  filled = false,
}: {
  size?: number;
  filled?: boolean;
}) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 20 20"
      fill={filled ? "currentColor" : "none"}
      stroke="currentColor"
      strokeWidth={filled ? 0 : 1.4}
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M10 1.5 12.9 7.4 19.4 8.3 14.7 12.9 15.8 19.4 10 16.3 4.2 19.4 5.3 12.9 0.6 8.3 7.1 7.4Z" />
    </svg>
  );
}

// A simple ribbon/bookmark shape — "save for later".
function BookmarkIcon({ size = 16 }: { size?: number }) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M3.5 2.5h9v11l-4.5-3-4.5 3Z" />
    </svg>
  );
}

const books = [
  {
    id: 1,
    title: "The Last Ember",
    author: "Nora Whitfield",
    cover: "linear-gradient(160deg, #e0623a 0%, #7a2d2d 55%, #1c1420 100%)",
    top: true,
    rating: 4,
    price: 13.29,
    originalPrice: 18.99,
  },
  {
    id: 2,
    title: "Nothing Is Certain",
    author: "Idris Falk",
    cover: "#13766a",
    top: false,
    rating: 3,
    price: 16.0,
    originalPrice: null,
  },
  {
    id: 3,
    title: "Back to the Garden",
    author: "Rosalind Pryce",
    cover: "#171923",
    top: false,
    rating: 4,
    price: 9.1,
    originalPrice: 13.0,
  },
  {
    id: 4,
    title: "Not the Way You Planned",
    author: "Marguerite Sol",
    cover: "linear-gradient(160deg, #cbd5e0 0%, #94a3b8 100%)",
    top: false,
    rating: 4,
    price: 9.6,
    originalPrice: null,
  },
];

export function ProductGrid() {
  return (
    // @container has to live on its OWN wrapper, not the grid element
    // whose @xl:/@3xl: column count is conditioned by it — an element
    // can't query its own container for its own styling (circular per the
    // CSS Containment spec), so those utilities would silently be
    // evaluated against the wrong (ancestor) container and never apply.
    <div className="@container w-full">
      <div className="grid gap-6 @xl:grid-cols-2 @3xl:grid-cols-4 p-10">
        {books.map((book) => (
          <Card key={book.id} shadow="sm" isHoverable>
            <CardBody className="flex flex-col gap-3 p-0">
              <div className="relative aspect-[3/4] w-full overflow-hidden rounded-t-[var(--r-lg)] bg-paper-sunken">
                <div
                  className="absolute inset-4 flex items-center justify-center rounded-[var(--r-sm)] p-4 text-center text-[0.95rem] font-bold uppercase leading-tight tracking-wide text-white/90"
                  style={{ background: book.cover }}
                >
                  {book.title}
                </div>
                {book.top ? (
                  <div className="absolute left-3 top-3">
                    <Chip size="sm" color="primary" variant="solid">
                      Top
                    </Chip>
                  </div>
                ) : null}
              </div>

              <div className="flex flex-col gap-2 px-4">
                <Chip
                  size="sm"
                  variant="soft"
                  color="warning"
                  classNames={{ base: "self-start" }}
                >
                  Paper Book
                </Chip>
                <div>
                  <p className="font-semibold text-ink">{book.title}</p>
                  <p className="text-[0.85rem] text-ink-muted">{book.author}</p>
                </div>
                <div className="flex items-center gap-0.5 text-[color:var(--oks-color-warning-500)]">
                  {Array.from({ length: 5 }, (_, i) => (
                    <StarIcon key={i} size={14} filled={i < book.rating} />
                  ))}
                </div>
                <p>
                  <span className="text-[1.15rem] font-bold text-[color:var(--oks-color-danger-700)]">
                    ${book.price.toFixed(2)}
                  </span>
                  {book.originalPrice ? (
                    <span className="ml-2 text-[0.85rem] text-ink-faint line-through">
                      ${book.originalPrice.toFixed(2)}
                    </span>
                  ) : null}
                </p>
              </div>
            </CardBody>
            <CardFooter className="flex gap-2 px-4 pb-4">
              <Button
                fullWidth
                color="danger"
                startContent={<CartIcon size={16} />}
              >
                Add To Cart
              </Button>
              <Button
                isIconOnly
                aria-label={`Save ${book.title} for later`}
                variant="bordered"
                color="danger"
              >
                <BookmarkIcon size={16} />
              </Button>
            </CardFooter>
          </Card>
        ))}
      </div>
    </div>
  );
}

More patterns

Commerce
CheckoutA real two-step checkout — order review, discount codes, and a live payment-method switch.
Commerce
Invoice detailA full-width invoice builder with a live Preview Drawer for the recipient-facing view.
App screens
Analytics dashboardA full fintech app screen — collapsible sidebar, header search, wallet cards, an earnings chart, savings goals, and a transactions table.