Commerce
Product card
A shop tile for one product: picture, name as the link, price, rating, badges, colours, quick add, wishlist.
- Built on
<article>aspect-ratio:has()Stretched link pseudo-elementaria-pressedrole="status"- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 5.0 kB
Try its options
Loading its options.
Code
import { ProductCard } from "@/components/equip/product-card";
export default function Demo() {
return (
<ProductCard
layout="row"
style={{ width: 224 }}
title="Linen shirt"
brand="Fernhill"
href="#linen-shirt"
price={68}
compareAt={85}
rating={4.6}
reviewCount={128}
defaultWishlisted={false}
media={
<svg viewBox="0 0 72 72" aria-hidden="true" style={{ color: "var(--eq-chart-2)" }}>
<path
d="M27 14 L18 18 L9 28 L16 35 L21 31 L21 60 L51 60 L51 31 L56 35 L63 28 L54 18 L45 14 Q36 22 27 14 Z"
fill="currentColor"
opacity="0.85"
/>
<path d="M27 14 Q36 22 45 14" fill="none" stroke="var(--eq-surface)" strokeWidth="1.5" />
<path d="M36 21 V58" stroke="var(--eq-surface)" strokeWidth="1" opacity="0.6" />
</svg>
}
/>
);
}Your versionchange its options in Look, This part
Examples
A homewares grid
Quick add waits for the promise it returns, then says Added and announces it politely; sold out disables it.
Sale
New
Only 3 left
Code
"use client";
import { ProductCard } from "@/components/equip/product-card";
const wait = () => new Promise<void>((resolve) => setTimeout(resolve, 800));
function Mug() {
return (
<svg viewBox="0 0 160 200" aria-hidden="true" style={{ color: "var(--eq-chart-1)" }}>
<path d="M48 70 h56 v66 a18 18 0 0 1 -18 18 h-20 a18 18 0 0 1 -18 -18 Z" fill="currentColor" />
<path d="M104 86 h8 a14 14 0 0 1 0 28 h-8" fill="none" stroke="currentColor" strokeWidth="8" />
<path d="M62 56 q6 -8 0 -16 M78 56 q6 -8 0 -16" fill="none" stroke="var(--eq-ink-3)" strokeWidth="3" strokeLinecap="round" />
</svg>
);
}
function Runner() {
return (
<svg viewBox="0 0 160 200" aria-hidden="true" style={{ color: "var(--eq-chart-4)" }}>
<rect x="54" y="30" width="52" height="140" rx="3" fill="currentColor" opacity="0.8" />
<path d="M54 60 h52 M54 140 h52" stroke="var(--eq-surface)" strokeWidth="3" />
<path d="M58 170 v8 M66 170 v8 M74 170 v8 M82 170 v8 M90 170 v8 M98 170 v8" stroke="currentColor" strokeWidth="2" />
</svg>
);
}
function Lamp() {
return (
<svg viewBox="0 0 160 200" aria-hidden="true" style={{ color: "var(--eq-chart-4)" }}>
<path d="M58 62 L102 62 L114 96 L46 96 Z" fill="currentColor" />
<path d="M80 96 V156" stroke="var(--eq-ink-2)" strokeWidth="4" />
<ellipse cx="80" cy="160" rx="26" ry="6" fill="var(--eq-ink-2)" />
</svg>
);
}
function Throw() {
return (
<svg viewBox="0 0 160 200" aria-hidden="true" style={{ color: "var(--eq-chart-3)" }}>
<path d="M38 70 h84 v70 q-42 14 -84 0 Z" fill="currentColor" opacity="0.75" />
<path d="M52 70 v76 M66 70 v78 M80 70 v79 M94 70 v78 M108 70 v76" stroke="var(--eq-surface)" strokeWidth="2" opacity="0.5" />
</svg>
);
}
export default function Example() {
return (
<div
style={{
display: "grid",
width: "100%",
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
gap: 12,
alignItems: "start",
}}
>
<ProductCard
title="Stoneware mug, 350 ml"
brand="Fernhill Kitchen"
href="#stoneware-mug"
media={<Mug />}
price={18}
compareAt={24}
rating={4.7}
reviewCount={1284}
badges={[{ label: "Sale", tone: "danger" }]}
swatches={[
{ name: "Rust", color: "#b8643f" },
{ name: "Oat", color: "#e6dcc8" },
{ name: "Sage", color: "#9aab8c" },
{ name: "Slate", color: "#4d5866" },
{ name: "Ink", color: "#1d2433" },
{ name: "Chalk", color: "#f5f2ea" },
]}
onQuickAdd={wait}
defaultWishlisted={false}
/>
<ProductCard
title="Linen table runner"
brand="Hearth and Loom"
href="#table-runner"
media={<Runner />}
price={42}
rating={4.4}
reviewCount={36}
badges={[{ label: "New" }]}
swatches={[
{ name: "Olive", color: "#7d7a3c" },
{ name: "Natural", color: "#d9cdb4" },
]}
onQuickAdd={wait}
defaultWishlisted={false}
/>
<ProductCard
title="Brass desk lamp"
brand="Northlight"
href="#desk-lamp"
media={<Lamp />}
price={129}
rating={4.8}
reviewCount={212}
badges={[{ label: "Only 3 left", tone: "warn" }]}
onQuickAdd={wait}
defaultWishlisted
/>
<ProductCard
title="Merino throw blanket"
brand="Hearth and Loom"
href="#merino-throw"
media={<Throw />}
price={145}
rating={4.9}
reviewCount={87}
soldOut
defaultWishlisted={false}
/>
</div>
);
}Row layout in a list
A 72px square thumbnail beside the details, for search results, a recently viewed rail or a narrow column.
Code
"use client";
import { ProductCard } from "@/components/equip/product-card";
const wait = () => new Promise<void>((resolve) => setTimeout(resolve, 600));
const items = [
{ id: "pour-over", title: "Ceramic pour-over set", brand: "Fernhill Kitchen", price: 54, rating: 4.5, reviews: 342, color: "var(--eq-chart-2)" },
{ id: "socks", title: "Merino crew socks, 3 pack", brand: "Hearth and Loom", price: 28, compareAt: 36, rating: 4.3, reviews: 91, color: "var(--eq-chart-5)" },
{ id: "planter", title: "Terracotta planter, 18 cm", brand: "Potting Shed", price: 22, rating: 4.8, reviews: 58, color: "var(--eq-chart-1)" },
];
export default function Example() {
return (
<ul style={{ display: "grid", gap: 8, margin: 0, padding: 0, listStyle: "none", width: "100%", maxWidth: 420 }}>
{items.map((item) => (
<li key={item.id}>
<ProductCard
layout="row"
title={item.title}
brand={item.brand}
href={`#${item.id}`}
price={item.price}
compareAt={item.compareAt}
rating={item.rating}
reviewCount={item.reviews}
onQuickAdd={wait}
defaultWishlisted={false}
media={
<svg viewBox="0 0 72 72" aria-hidden="true" style={{ color: item.color }}>
<rect x="20" y="24" width="32" height="34" rx="6" fill="currentColor" opacity="0.85" />
<rect x="16" y="18" width="40" height="8" rx="3" fill="currentColor" />
</svg>
}
/>
</li>
))}
</ul>
);
}Euro prices in German
currency and locale format the price and the unit price; labels translate the fixed wording.
Neu
Code
import { ProductCard } from "@/components/equip/product-card";
export default function Example() {
return (
<ProductCard
lang="de"
style={{ width: 200 }}
aspect="1/1"
title="Kaffeebohnen Hausmischung, 250 g"
brand="Rösterei Elbufer"
href="#hausmischung"
price={12.9}
compareAt={14.5}
unitPrice={{ amount: 5.16, per: "100 g" }}
currency="EUR"
locale="de-DE"
rating={4.6}
reviewCount={1284}
badges={[{ label: "Neu" }]}
defaultWishlisted={false}
labels={{
wishlist: "{title} auf die Wunschliste",
rating: "Bewertet mit {rating} von 5, {count} Bewertungen",
ratingOnly: "Bewertet mit {rating} von 5",
unitPrice: "{price} pro {unit}",
}}
media={
<svg viewBox="0 0 200 200" aria-hidden="true" style={{ color: "var(--eq-chart-1)" }}>
<path d="M66 52 h68 l8 18 v86 a6 6 0 0 1 -6 6 h-72 a6 6 0 0 1 -6 -6 v-86 Z" fill="currentColor" opacity="0.85" />
<path d="M58 70 h84" stroke="var(--eq-surface)" strokeWidth="3" />
<ellipse cx="100" cy="112" rx="14" ry="20" fill="var(--eq-surface)" opacity="0.8" />
<path d="M100 94 q-6 18 0 36" fill="none" stroke="currentColor" strokeWidth="2" />
</svg>
}
/>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
title | string | none | Product name, set as a heading and the card's one link. |
href | string | none | Where the card goes. The link's ::after covers the whole card. |
media | ReactNode | none | The picture: an img, picture or svg, filling the frame and cropped to it. |
brand | ReactNode | none | Brand or a short subtitle, shown above the name and read after it. |
price | number | none | Price in major units, drawn by Price. |
compareAt | number | none | The earlier price, struck through when higher than price. |
unitPrice | { amount: number; per: string } | none | A small secondary line such as 1.20 per 100 g, formatted in the card's currency. |
currency | string | "USD" | ISO 4217 code. |
locale | string | "en-US" | Formats the prices, rating and counts. |
rating | number | none | Average stars, shown as stars and a number. A row shows one star. |
reviewCount | number | none | Shown in brackets after the rating, with grouping. |
badges | { label: string; tone?: "neutral" | "ok" | "warn" | "danger"; variant?: "soft" | "solid" }[] | none | Labels such as New, Sale or Only 3 left. Solid over the picture in a column, soft in a row. |
swatches | { name: string; color: string }[] | none | Colours it comes in. Up to four dots, then a count of the rest; read as 6 colours. Not interactive. |
onQuickAdd | () => void | Promise<void> | none | Shows the quick add button. A returned promise shows progress until it settles, then Added. |
soldOut | boolean | false | Disables quick add, says Sold out and dims the picture. |
wishlisted | boolean | none | Whether the item is saved, controlled. |
defaultWishlisted | boolean | none | Whether it starts saved. Passing it alone shows the heart, uncontrolled. |
onWishlistChange | (wishlisted: boolean) => void | none | Called with the new saved state. The heart shows when this, wishlisted or defaultWishlisted is given. |
layout | "column" | "row" | "column" | column stacks the picture over the details; row puts a 72px square thumbnail beside them. |
aspect | "4/5" | "1/1" | "4/5" | Shape of the picture frame in the column layout. |
headingLevel | 2 | 3 | 4 | 3 | Level of the name heading. |
labels | Partial<ProductCardLabels> | none | Wording templates for another language, with {title}, {count}, {rating}, {price} and {unit} filled in. |
...rest | ComponentProps<"article"> | none | Every native article attribute, including lang and style. |
Anatomy
| root | .eq-product-card | An article labelled by the name; the only positioned box. |
|---|---|---|
| media | .eq-product-card-media | Holds its aspect ratio, so the card does not shift as the picture loads. |
| badges | .eq-product-card-badges | Over the picture's top corner in a column, above the name in a row. |
| info | .eq-product-card-info | |
| title | .eq-product-card-title | Clamped to two lines. |
| link | .eq-product-card-link | Its ::after covers the card. |
| brand | .eq-product-card-brand | |
| rating | .eq-product-card-rating | |
| reviews | .eq-product-card-reviews | The review count in brackets. |
| price | .eq-product-card-price | Price and the unit price line. |
| unit | .eq-product-card-unit | |
| swatches | .eq-product-card-swatches | |
| dots | .eq-product-card-dots | |
| dot | .eq-product-card-dot | |
| more | .eq-product-card-more | The +2 after four dots. |
| wish | .eq-product-card-wish | The heart toggle button. |
| heart | .eq-product-card-heart | The heart Icon, filled while saved; it pops when pressed. |
| add | .eq-product-card-add | The quick add Button. |
| hidden | .eq-product-card-hidden | Visually hidden text, including the polite status region. |
Keyboard
| Tab | Focuses the name link, then the heart, then quick add. The link's ring is drawn around the whole card. |
|---|---|
| Enter | On the link, opens the product. |
| EnterSpace | On the heart, saves or unsaves; on quick add, adds to the cart. |
Notes
- The card is one real link, the name, so screen readers announce it by name and not by every word on the card.
- The heart and quick add sit above the stretched link, so they keep their own clicks and focus.
- The heart keeps one name, Save Linen shirt to wishlist, and aria-pressed says whether it is on.
- Quick add is described by the product name, so a grid of Add to cart buttons is still told apart.
- When an item is added, a polite status region announces it; the Added label returns to Add to cart after a moment.
- While quick add is pending the button keeps focus and reads Adding with a spinner; a second press is ignored.
- Stars are hidden from assistive tech and replaced by one sentence such as Rated 4.6 out of 5, 128 reviews.
- The colour dots are hidden from assistive tech and read as a count of colours.
- Do not wrap the card in a link: nested interactive elements are invalid. Pass href instead.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/product-card.tsx285 lines
"use client";
import { useEffect, useId, useState } from "react";
import type { ComponentProps, ReactNode } from "react";
import { Badge } from "./badge";
import { Button } from "./button";
import { Icon } from "./icon";
import { Price } from "./price";
import { Rating } from "./rating";
import "./product-card.css";
export type ProductCardLabels = {
addToCart: string;
adding: string;
added: string;
soldOut: string;
/** Spoken once the item is in the cart: "{title} added to cart". */
addedAnnouncement: string;
/** Names the heart: "Save {title} to wishlist". */
wishlist: string;
/** "{count} colours". */
colours: string;
/** "Rated {rating} out of 5, {count} reviews". */
rating: string;
/** "Rated {rating} out of 5", when there is no review count. */
ratingOnly: string;
/** "{price} per {unit}". */
unitPrice: string;
};
const english: ProductCardLabels = {
addToCart: "Add to cart",
adding: "Adding",
added: "Added",
soldOut: "Sold out",
addedAnnouncement: "{title} added to cart",
wishlist: "Save {title} to wishlist",
colours: "{count} colours",
rating: "Rated {rating} out of 5, {count} reviews",
ratingOnly: "Rated {rating} out of 5",
unitPrice: "{price} per {unit}",
};
export type ProductCardBadge = {
label: string;
tone?: "neutral" | "ok" | "warn" | "danger";
variant?: "soft" | "solid";
};
export type ProductCardProps = Omit<ComponentProps<"article">, "title"> & {
/** Product name. Also the card's one link. */
title: string;
href: string;
/** The product picture: an img, picture or svg. Fills the frame and is cropped to it. */
media?: ReactNode;
/** Brand or a short subtitle, shown above the name. */
brand?: ReactNode;
/** Price in major units. */
price: number;
/** The earlier price, struck through when higher than price. */
compareAt?: number;
/** Unit price for groceries and the like, such as 1.20 per "100 g". */
unitPrice?: { amount: number; per: string };
currency?: string;
locale?: string;
/** Average stars. Shown with reviewCount as "4.6 (128)". */
rating?: number;
reviewCount?: number;
/** Labels over the picture, such as New, Sale or Only 3 left. */
badges?: ProductCardBadge[];
/** Colours it comes in. Up to four dots are shown, then a count of the rest. */
swatches?: { name: string; color: string }[];
/** Shows the quick add button. May return a promise; the button shows progress until it settles. */
onQuickAdd?: () => void | Promise<void>;
/** Disables quick add and says Sold out. */
soldOut?: boolean;
/** Whether the item is saved, controlled. */
wishlisted?: boolean;
/** Whether the item starts saved, uncontrolled. Passing it alone shows the heart. */
defaultWishlisted?: boolean;
/** Called with the new saved state. The heart shows when this, wishlisted or defaultWishlisted is given. */
onWishlistChange?: (wishlisted: boolean) => void;
/** column stacks the picture over the details; row puts a thumbnail beside them. */
layout?: "column" | "row";
/** Shape of the picture frame in the column layout. */
aspect?: "4/5" | "1/1";
headingLevel?: 2 | 3 | 4;
/** Wording, for another language. */
labels?: Partial<ProductCardLabels>;
};
const fill = (template: string, values: Record<string, string>) =>
template.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match);
export function ProductCard({
title,
href,
media,
brand,
price,
compareAt,
unitPrice,
currency = "USD",
locale = "en-US",
rating,
reviewCount,
badges,
swatches,
onQuickAdd,
soldOut = false,
wishlisted,
defaultWishlisted,
onWishlistChange,
layout = "column",
aspect = "4/5",
headingLevel = 3,
labels,
className,
...rest
}: ProductCardProps) {
const id = useId();
const words = { ...english, ...labels };
const [savedInner, setSavedInner] = useState(defaultWishlisted ?? false);
const saved = wishlisted ?? savedInner;
// Set only from the click, so a card that starts saved does not pop on mount.
const [popped, setPopped] = useState(false);
const [phase, setPhase] = useState<"idle" | "pending" | "added">("idle");
useEffect(() => {
if (phase !== "added") return;
const timer = setTimeout(() => setPhase("idle"), 2400);
return () => clearTimeout(timer);
}, [phase]);
const toggleSaved = () => {
const next = !saved;
if (wishlisted === undefined) setSavedInner(next);
setPopped(next);
onWishlistChange?.(next);
};
const quickAdd = async () => {
if (!onQuickAdd || phase === "pending") return;
setPhase("pending");
try {
await onQuickAdd();
setPhase("added");
} catch {
setPhase("idle");
}
};
const whole = new Intl.NumberFormat(locale);
const one = new Intl.NumberFormat(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const money = new Intl.NumberFormat(locale, { style: "currency", currency });
const titleId = `${id}-title`;
const Heading = `h${headingLevel}` as "h3";
const shownSwatches = swatches?.slice(0, 4) ?? [];
const moreSwatches = (swatches?.length ?? 0) - shownSwatches.length;
const badgeList =
badges && badges.length > 0 ? (
<div className="eq-product-card-badges">
{badges.map((b) => (
<Badge key={b.label} tone={b.tone} variant={b.variant ?? (layout === "row" ? "soft" : "solid")}>
{b.label}
</Badge>
))}
</div>
) : null;
return (
<article
aria-labelledby={titleId}
{...rest}
className={["eq-product-card", className].filter(Boolean).join(" ")}
data-layout={layout}
data-aspect={layout === "column" ? aspect : undefined}
data-sold-out={soldOut || undefined}
>
<div className="eq-product-card-info">
<Heading className="eq-product-card-title" id={titleId}>
<a className="eq-product-card-link" href={href}>
{title}
</a>
</Heading>
{brand != null && <p className="eq-product-card-brand">{brand}</p>}
{layout === "row" && badgeList}
{rating !== undefined && (
<div className="eq-product-card-rating">
{/* A row is narrow, so it shows one star beside the number instead of five. */}
<Rating
label={fill(words.ratingOnly, { rating: one.format(rating) })}
value={layout === "row" ? 1 : rating}
max={layout === "row" ? 1 : 5}
readOnly
allowHalf
size="s"
aria-hidden="true"
/>
<span aria-hidden="true">
{one.format(rating)}
{reviewCount !== undefined && (
<span className="eq-product-card-reviews"> ({whole.format(reviewCount)})</span>
)}
</span>
<span className="eq-product-card-hidden">
{fill(reviewCount === undefined ? words.ratingOnly : words.rating, {
rating: one.format(rating),
count: whole.format(reviewCount ?? 0),
})}
</span>
</div>
)}
<div className="eq-product-card-price">
<Price amount={price} compareAt={compareAt} currency={currency} locale={locale} size="s" />
{unitPrice && (
<span className="eq-product-card-unit">
<span aria-hidden="true">
{money.format(unitPrice.amount)} / {unitPrice.per}
</span>
<span className="eq-product-card-hidden">
{fill(words.unitPrice, { price: money.format(unitPrice.amount), unit: unitPrice.per })}
</span>
</span>
)}
</div>
{swatches && swatches.length > 0 && (
<p className="eq-product-card-swatches">
<span className="eq-product-card-dots" aria-hidden="true">
{shownSwatches.map((s) => (
<span key={s.name} className="eq-product-card-dot" style={{ background: s.color }} />
))}
{moreSwatches > 0 && <span className="eq-product-card-more">+{whole.format(moreSwatches)}</span>}
</span>
<span className="eq-product-card-hidden">{fill(words.colours, { count: whole.format(swatches.length) })}</span>
</p>
)}
</div>
<div className="eq-product-card-media">{media}</div>
{layout === "column" && badgeList}
{(onWishlistChange || wishlisted !== undefined || defaultWishlisted !== undefined) && (
<button
type="button"
className="eq-product-card-wish"
aria-pressed={saved}
aria-label={fill(words.wishlist, { title })}
data-popped={popped || undefined}
onClick={toggleSaved}
>
<Icon name="heart" className="eq-product-card-heart" filled={saved} size={layout === "row" ? 18 : 16} />
</button>
)}
{(onQuickAdd || soldOut) && (
<Button
className="eq-product-card-add"
size="s"
variant="outline"
disabled={soldOut}
// Loading keeps keyboard focus on the button while the add runs.
loading={phase === "pending"}
aria-describedby={titleId}
onClick={quickAdd}
lead={
phase === "added" ? (
<Icon name="check" size="s" />
) : (
<Icon name="cart" size="s" />
)
}
>
{soldOut ? words.soldOut : phase === "pending" ? words.adding : phase === "added" ? words.added : words.addToCart}
</Button>
)}
<span className="eq-product-card-hidden" role="status">
{phase === "pending" ? words.adding : phase === "added" ? fill(words.addedAnnouncement, { title }) : ""}
</span>
</article>
);
}components/equip/product-card.css328 lines
@layer equip {
.eq-product-card {
--_pad: var(--eq-space-2);
--_radius: var(--eq-radius-l);
--_ratio: 4 / 5;
--_thumb: 72px;
/* The one positioned box: the link's ::after stretches over it. */
position: relative;
box-sizing: border-box;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"media"
"info";
align-content: start;
gap: var(--eq-space-2);
min-width: 0;
margin: 0;
padding: var(--_pad);
border: 1px solid var(--eq-line);
border-radius: var(--_radius);
background: var(--eq-surface);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
transition:
translate var(--eq-dur-move) var(--eq-spring),
scale var(--eq-dur) var(--eq-ease),
border-color var(--eq-dur) var(--eq-ease);
}
/* The action row exists only when there is an action, so no empty gap is left below. */
.eq-product-card[data-layout="column"]:has(> .eq-product-card-add) {
grid-template-areas:
"media"
"info"
"action";
}
.eq-product-card[data-aspect="1/1"] {
--_ratio: 1 / 1;
}
.eq-product-card[data-layout="row"] {
grid-template-columns: var(--_thumb) minmax(0, 1fr);
grid-template-areas: "media info";
column-gap: var(--eq-space-3);
row-gap: var(--eq-space-2);
--_ratio: 1 / 1;
}
.eq-product-card[data-layout="row"]:has(> .eq-product-card-add) {
grid-template-areas:
"media info"
"media action";
}
/* The heart's column exists only when there is a heart, so no empty gap is left at the end. */
.eq-product-card[data-layout="row"]:has(> .eq-product-card-wish) {
grid-template-columns: var(--_thumb) minmax(0, 1fr) auto;
grid-template-areas: "media info wish";
}
.eq-product-card[data-layout="row"]:has(> .eq-product-card-wish):has(> .eq-product-card-add) {
grid-template-areas:
"media info wish"
"media action action";
}
.eq-product-card-media {
grid-area: media;
display: grid;
aspect-ratio: var(--_ratio);
overflow: hidden;
border-radius: calc(var(--_radius) - var(--_pad) + 2px);
background: var(--eq-tint);
transition: opacity var(--eq-dur) var(--eq-ease);
}
.eq-product-card[data-layout="row"] .eq-product-card-media {
align-self: start;
border-radius: var(--eq-radius-m);
}
.eq-product-card-media > :is(img, svg, picture, video, div, span) {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.eq-product-card[data-sold-out] .eq-product-card-media {
opacity: 0.6;
}
.eq-product-card-badges {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--eq-space-1);
}
/* In the column layout the badges ride on the top corner of the picture. */
.eq-product-card[data-layout="column"] .eq-product-card-badges {
grid-area: media;
align-self: start;
justify-self: start;
position: relative;
z-index: 1;
margin: var(--eq-space-2);
max-width: calc(100% - var(--eq-space-2) * 2 - 40px);
pointer-events: none;
}
.eq-product-card-info {
grid-area: info;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
padding-inline: var(--eq-space-1);
}
.eq-product-card[data-layout="row"] .eq-product-card-info {
padding-inline: 0;
}
.eq-product-card-info > :is(p, div) {
margin: 0;
}
.eq-product-card-title {
margin: 0;
font-size: var(--eq-text-s);
font-weight: var(--eq-weight-strong);
line-height: 1.35;
letter-spacing: -0.005em;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
.eq-product-card-link {
color: inherit;
text-decoration: none;
}
.eq-product-card-link::after {
content: "";
position: absolute;
/* 2px past the bottom edge, so the hover lift does not slide the card out from under the cursor. */
inset: 0 0 -2px;
border-radius: var(--_radius);
}
.eq-product-card-link:focus-visible {
outline: none;
}
.eq-product-card:has(.eq-product-card-link:focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
/* The brand reads after the name but sits above it, as a label does. */
.eq-product-card-brand {
order: -1;
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
line-height: 1.4;
}
.eq-product-card[data-layout="row"] .eq-product-card-badges {
order: -2;
margin-bottom: 2px;
}
.eq-product-card-rating {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--eq-space-1);
color: var(--eq-ink);
font-size: var(--eq-text-xs);
font-variant-numeric: tabular-nums;
}
.eq-product-card-reviews {
color: var(--eq-ink-2);
}
.eq-product-card-price {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: var(--eq-space-2);
margin-top: 2px;
}
.eq-product-card-unit {
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.eq-product-card-swatches {
margin-top: var(--eq-space-1);
}
.eq-product-card-dots {
display: flex;
align-items: center;
gap: var(--eq-space-1);
}
.eq-product-card-dot {
box-sizing: border-box;
width: 12px;
height: 12px;
border-radius: var(--eq-radius-full);
/* A hairline so pale colours still show on a pale card. */
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--eq-ink) 22%, transparent);
}
.eq-product-card-more {
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-variant-numeric: tabular-nums;
}
/* Controls sit above the stretched link, so they keep their own clicks and focus. */
.eq-product-card-wish,
.eq-product-card-add {
position: relative;
z-index: 1;
}
.eq-product-card-wish {
grid-area: media;
align-self: start;
justify-self: end;
display: grid;
place-items: center;
width: var(--eq-h-s);
height: var(--eq-h-s);
margin: var(--eq-space-2);
padding: 0;
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-full);
background: var(--eq-raised);
color: var(--eq-ink);
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-product-card[data-layout="row"] .eq-product-card-wish {
grid-area: wish;
margin: calc(-1 * var(--eq-space-1)) calc(-1 * var(--eq-space-1)) 0 0;
border-color: transparent;
background: transparent;
}
.eq-product-card-wish[data-popped] .eq-product-card-heart {
animation: eq-product-card-pop var(--eq-dur-move) var(--eq-spring);
}
@keyframes eq-product-card-pop {
from {
scale: 0.6;
}
}
@media (hover: hover) {
.eq-product-card-wish:hover {
background: color-mix(in srgb, var(--eq-raised) 80%, var(--eq-ink));
}
.eq-product-card[data-layout="row"] .eq-product-card-wish:hover {
background: var(--eq-tint);
}
.eq-product-card:has(.eq-product-card-link:hover) {
translate: 0 -2px;
border-color: var(--eq-line-strong);
}
}
.eq-product-card-wish:active {
scale: var(--eq-press);
}
.eq-product-card:has(.eq-product-card-link:active) {
scale: var(--eq-press);
}
.eq-product-card-wish:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-product-card-add {
grid-area: action;
width: 100%;
}
.eq-product-card[data-layout="row"] .eq-product-card-add {
justify-self: start;
width: auto;
}
.eq-product-card-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}