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.
How it's built
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.
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.
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.
Only plans with an `originalPrice` render the struck-through price — `null` means "not on sale", not "$0 off".
Source
One file · copy & pasteimport { 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>
);
}