Guides · September 5, 2023
Building a Product Card System with shadcn/ui
A shadcn product card is not one component — it is a system of composable primitives (Card, AspectRatio, Badge, Skeleton) wired to real commerce data. Here is how to build one that scales across a catalog without turning into a pile of one-off variants.
By Polo Themes
A production-grade product card in shadcn/ui is built from a small set of composable primitives — Card, AspectRatio, Badge, Skeleton, and Button — assembled into a single typed component that accepts a normalized product shape and renders every state your catalog actually produces: in stock, out of stock, on sale, loading, and empty. The mistake most teams make is treating the product card as one static JSX block copy-pasted into every page that needs it; the fix is treating it as a small design system of its own, with one source of truth for layout and clearly separated slots for image, price, badges, and actions. This guide builds that system from the primitives up, covers the traps that show up once real data — missing images, long titles, variant pricing — hits it, and ends with a pattern for keeping it consistent across a Next.js storefront.
Why a product card gets complicated fast
A product card looks like the simplest component in a storefront: an image, a title, a price, maybe a button. In practice it is one of the highest-leverage components in the whole app, because it renders dozens or hundreds of times per page, carries almost all of your Core Web Vitals risk (it is usually the largest chunk of above-the-fold image weight), and has to gracefully handle every edge case your catalog can produce — a product with no image, a title that is four words in the design mock and forty characters in production, a price that is actually a range once variants are involved, and a sale badge that needs to coexist with a low-stock badge without the layout jumping around.
shadcn/ui is a good foundation for this specifically because it is not a component library in the traditional sense — you do not install a black-box, pre-built product-card component from a package registry and fight its prop API. You copy accessible, unstyled-by-default primitives, built on Radix, directly into your codebase, then compose and own them. That ownership model is exactly what a product card needs, because every commerce catalog has slightly different data shapes and every brand has slightly different visual rules for sale pricing, badges, and hover states. You are not fighting someone else's opinionated component — you are assembling your own from parts that already handle accessibility and keyboard interaction correctly.
The primitives you actually need
Before writing a single line of the card itself, install the shadcn primitives it will be built from. Assuming a shadcn/ui project already scaffolded with the CLI, add the following components:
- Card — the outer container (Card, CardHeader, CardContent, CardFooter) that gives you consistent padding, border, and radius tokens instead of hand-rolled div soup.
- Aspect Ratio — locks the image container to a fixed ratio so the grid never reflows while images load, which is the single biggest layout-shift source in product grids.
- Skeleton — a loading placeholder that matches the card's real dimensions, used for the initial render before data or images resolve.
- Badge — for sale, new, low-stock, and sold-out labels layered on top of the image.
- Button — for the add-to-cart or quick-view action, usually rendered on hover on desktop and always-visible on touch.
That is a deliberately small list. Resist the urge to add a carousel, a tooltip, and a dialog to the base card just because a competitor's product page has them — those belong in a quick-view modal or the product detail page, not in the card that renders two hundred times on a category grid. Every extra primitive you wire into the base card is extra JavaScript shipped to every visitor who never uses it.
Step 1: Define the shape the card consumes, not the shape your API returns
The most common structural mistake is building the card component directly against whatever your commerce backend returns — a raw Shopify Storefront API product node, a raw Medusa product object, a raw payment provider's price object. That couples your presentational component to your data layer, and it means every backend migration — moving to headless, a Medusa version bump, adding a second sales channel — forces a rewrite of the UI. Define a small, boring, presentation-only type instead, and map your backend response onto it at the data-fetching boundary. A workable shape looks like this:
- id — a stable identifier used for keys and analytics events.
- slug or href — where the card links to.
- title — already truncation-safe length, or truncated in the mapping layer, not in the component.
- imageUrl and imageAlt — alt text should never be optional; fall back to the title if the source data has none.
- price and compareAtPrice — both already formatted as display strings, or both raw numbers plus a currency code, with formatting done by one shared formatter, never scattered inline number formatting calls across components.
- badge — a single, already-decided label such as Sale, Sold out, or New, rather than raw inventory counts the component has to interpret.
- inStock — a boolean, decided server-side, not inferred client-side from a quantity field that may not even be present.
This mapping step is the single highest-leverage decision in the whole system. It means your card component has exactly one contract to satisfy, and when the backend changes shape — a new variant model, new pricing rules, a migration to a different platform — you update one mapping function instead of hunting through every card usage in the app.
Step 2: Build the card as a composition, not a monolith
With the shadcn Card primitives installed, the component becomes an assembly of small, named regions rather than one large return statement. A useful mental model borrowed from design-system practice: the card has an image region (fixed aspect ratio, badges layered via absolute positioning, a skeleton shown until the image has loaded or while data is pending), a content region (title, price, and optional secondary text like a color or size count), and an action region (the add-to-cart or quick-view button, which on desktop often only appears on hover and on mobile is either always visible or lives behind a tap-to-reveal state).
Keep each region as its own small internal component or clearly commented block. This pays off the first time you need a compact variant for a related-products rail versus a full variant for the main category grid — you are recombining the same regions with different sizing tokens, not maintaining two divergent copies of the whole card.
Step 3: Handle every real state, not just the happy path
A card component is only finished once it correctly renders the states a real catalog will actually produce, not just the one perfect product in the design mock:
- Loading — render a skeleton with the exact same outer dimensions as the resolved card (same aspect ratio, same content-region height), so the grid does not jump when data resolves. This is worth testing on a throttled connection, not just assuming it works.
- Missing image — fall back to a neutral placeholder inside the same aspect-ratio wrapper rather than collapsing the image region to zero height, which breaks the grid's row alignment.
- Sold out — desaturate the image slightly with a CSS filter rather than a separate image asset, swap the action button for a disabled state or a notify-me affordance, and keep the badge visible rather than hiding the card entirely — hidden sold-out products break pagination counts and confuse anyone arriving from a shared link.
- Sale pricing — show the compare-at price with a strikethrough and the current price in the accent color, and make sure the two never wrap onto separate lines at narrow card widths, which is a common overflow bug once currency symbols and longer numbers are involved.
- Long titles — clamp to two lines with a CSS line-clamp utility rather than truncating with an ellipsis at a fixed character count; character-count truncation reads oddly across different average word lengths and breaks entirely for non-English catalogs.
- Empty grid — the card system should include, or hand off cleanly to, an empty-state component for zero search or filter results; it is a UX dead end most teams forget until QA finds it.
Step 4: Wire in Next.js image and link primitives correctly
Two Next.js specifics matter enough to call out directly. First, the image inside the aspect-ratio wrapper should use the framework's image component with an explicit sizes attribute that matches your actual grid breakpoints — a four-column desktop grid that collapses to two columns on mobile needs a sizes value reflecting that, not a lazy full-viewport-width default. Getting this wrong is one of the most common causes of oversized image downloads on category pages. Second, only the first few cards above the fold on the initial category view should be marked as priority-loaded; marking every card in a two-hundred-product grid as high priority defeats the purpose of prioritization entirely and can slow down the very images you meant to speed up.
The whole card should also be a single semantic link — wrap the card in an anchor, or make the title the anchor and give the rest of the card a click-through handler that respects it, so keyboard and screen-reader users get one predictable target, not a card full of nested interactive elements fighting for focus order. This is one of the areas where building on Radix-based shadcn primitives pays off automatically: focus states and keyboard activation on the button and card come pre-wired correctly, so you are not re-deriving accessible interaction patterns from scratch.
Step 5: Theme it with tokens, not one-off utility classes
shadcn/ui ships with CSS variable-based design tokens — background, foreground, muted, accent, border, and so on — rather than hard-coded color utilities baked into each component. Keep the product card consuming those semantic tokens: the sale-price color should read from your accent token, the sold-out overlay from your muted-foreground token, rather than reaching for raw one-off color utilities scattered through the component. Token-based theming is what lets a single card component serve a dark-mode storefront, a seasonal re-skin, or a multi-brand headless setup without touching component logic — you change the token values in one place and every card, badge, and button updates together. This is the same discipline behind a well-built Figma-to-code component library: the visual decisions live in tokens, and the component just consumes them.
Step 6: Keep variants explicit, not implicit
As the card gets reused — a dense grid variant, a horizontal list-item variant for search results, a compact variant for an also-bought rail — resist accumulating a pile of boolean props that quietly multiply into dozens of untested combinations. A single explicit variant prop, for example a value of grid, list, or compact, that switches between a small number of deliberately designed layouts is far easier to reason about, test, and hand off to a designer reviewing the component in isolation. If you find yourself wanting a fourth variant, that is usually a signal the underlying data shape needs another look rather than another prop.
Where this fits if you are starting from a Figma design
If the visual design for the card already exists as a well-structured Figma file — components with real auto-layout, named layers, and token-based color styles rather than flattened frames — the mapping from design to this shadcn structure is direct: each Figma component maps to a card region, each color style maps to a CSS variable, and the states above (loading, sold-out, sale) should already exist as design variants you can hand to an AI design-to-code tool or a developer without them having to guess. Cleanly structured, token-based design systems built this way — as Polo Themes' own Figma UI kits are — translate far more cleanly into a system like this than a one-off static mockup, because the token and component boundaries already match what shadcn expects on the code side.
A note on where headless commerce is heading
This pattern — typed presentation props, composable primitives, token-based theming — is also exactly what makes a component library portable across commerce backends. A card built this way does not care whether the mapping layer behind it talks to Shopify, Medusa, or a custom API; it only cares that the mapping function produces the same small, boring shape. That separation is becoming more important as more storefronts move toward headless and composable architectures, and it is also the shape that AI coding tools handle best: a well-typed, well-named component with an explicit prop contract is far easier for an agent to extend correctly than a monolithic block full of inline conditionals. Building product cards this way now is good engineering regardless of what tooling touches your codebase next, and it happens to also be the most future-proof choice as design-to-code and AI-assisted frontend workflows keep maturing.
Frequently Asked Questions
Do I need Radix or shadcn specifically, or does this pattern work with any component library?
The composition pattern — typed props, separated regions, explicit variants, token-based theming — works with any component approach. shadcn/ui is a strong fit because you own the source directly and it is built on Radix primitives, so accessibility and keyboard behavior are correct by default without you having to fight an opaque third-party API.
Should the add-to-cart button live inside the card or open a quick-view modal?
Both are valid; the decision usually comes down to whether your products have variants that must be chosen before adding to cart. A single-variant catalog can add directly from the card. A catalog with size or color variants generally needs a quick-view modal or a redirect to the product page, since a card is too small to host a full variant picker without harming the grid's density.
How do I avoid layout shift when product images load?
Wrap every image in a fixed-ratio container, such as shadcn's Aspect Ratio primitive, sized before the image resolves, and render a skeleton of identical dimensions while the image or data is loading. Never let the card's height depend on the image's natural dimensions loading first.
Is this pattern overkill for a small catalog?
Even a twenty-product catalog benefits from the typed-props and separated-regions discipline, because it is what lets you add a sale badge or a sold-out state later without a rewrite. The parts that scale with catalog size are mainly image prioritization and variant proliferation — those matter more as the catalog grows, but the underlying structure is worth setting up from the first card.