Skip to content

ProductCard, ProductImage & StockBadge

npm package: import it from 'flootlets'
bun add flootlets
Or copy it in with the shadcn CLI: import it from '@/components/ui/…'
npx 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.json

Both need Tailwind CSS v4; see Installation for the setup.

In stockSale

Kuih Lapis

Sale price RM 12.90 Original price RM 15.90
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”.

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