ProductCard, ProductImage & StockBadge
bun add flootletsnpx shadcn@latest add https://dyeoh.github.io/flootlets/r/product-card.json https://dyeoh.github.io/flootlets/r/product-image.json https://dyeoh.github.io/flootlets/r/stock-badge.jsonBoth need Tailwind CSS v4; see Installation for the setup.
import { For, Show } from 'solid-js';import { placeholder, products } from './placeholder';
export default function ProductCardExample() { return ( <> <Grid minItemWidth="12rem" class="w-full"> <For each={products.slice(0, 3)}> {(p) => ( <ProductCard> <ProductCard.Image urls={placeholder(p.name, p.hue)} width={400} height={400} /> <ProductCard.Badges> <StockBadge inStock={p.inStock} /> <Show when={p.compareAt}> <Badge>Sale</Badge> </Show> </ProductCard.Badges> <ProductCard.Title href={`#${p.slug}`}>{p.name}</ProductCard.Title> <ProductCard.Price amount={{ amount: p.price, currency: 'MYR' }} compareAt={p.compareAt ? { amount: p.compareAt, currency: 'MYR' } : null} locale="en-MY" /> <ProductCard.Actions> <Button size="sm" disabled={!p.inStock} onClick={() => toast({ title: 'Added to cart', description: p.name, variant: 'success' }) } > {p.inStock ? 'Add to cart' : 'Sold out'} </Button> </ProductCard.Actions> </ProductCard> )} </For> </Grid> <Toaster /> </> );}ProductCard is put together from parts (Image, Badges, Title, Price, Actions), so
each shop arranges just what it needs. The whole card is clickable through the title’s link, but
screen readers hear a single link named after the product, not a card full of nested links. Its
Actions stay clickable above that link. The card is presentational: fetch products however your
app does and pass the data in.
The card’s photo has an empty alt by default, since the title right next to it already names
the product and saying it twice is noise for screen-reader users. Product pages should use
ProductImage with a real description instead.
ProductImage takes an image’s two sizes, urls: { thumb, large } (from the gnerkulfloot API: a
400px thumb and a 1600px large WebP), and lets the browser pick the smallest that looks sharp. Pass the original
width/height, so the space is reserved and the page doesn’t jump while it loads.
Use priority for the main photo at the top of a product page; everything else loads lazily.
StockBadge turns an in_stock boolean into “In stock” or “Sold out”.
Mapping gnerkulfloot API data
Section titled “Mapping gnerkulfloot API data”The product list (GET /v1/products) returns only the thumbnail URL. gnerkulfloot always stores
both sizes side by side (…/thumb.webp and …/large.webp), so derive the large one. Don’t pass
the thumbnail as both: the browser would then treat a 400px image as 1600px and show it blurry on
big screens.
const imageUrls = (thumb: string | null) => thumb ? { thumb, large: thumb.replace(/thumb\.webp$/, 'large.webp') } : null;
// GET /v1/products → items[]<ProductCard> <ProductCard.Image urls={imageUrls(item.thumbnail)} /> <ProductCard.Title href={`/products/${item.slug}`}>{item.name}</ProductCard.Title> <ProductCard.Price amount={item.price_from} compareAt={item.compare_at} locale="en-MY" /> <StockBadge inStock={item.in_stock} /></ProductCard>;| ProductImage prop | Type | |
|---|---|---|
urls |
{ thumb: string; large: string } |
From the API |
alt |
string |
Required; "" only when the product is named right beside it |
width, height |
number |
The original’s size, from the API |
priority |
boolean |
Load now, high priority |
sizes |
string |
Default suits a responsive grid |
fallback |
JSX | Shown if the image fails to load; defaults to the alt text |
flootlets 0.2.3 · Changelog