Make product card row alignment fully dynamic via CSS Grid subgrid

Replaces the fixed line-clamp-2 subline cap with real subgrid alignment:
each grid (ProductGrid/RelatedProducts/MerklisteGrid) declares 6 explicit
row-tracks (image/header/price/belowPrice/lowstock/button, last one
minmax(0,1fr)), and every card spans those same 6 tracks via
grid-template-rows: subgrid — so row heights are genuinely shared across
a row of cards. A subline can now wrap to any number of lines (3, 4, ...)
without being truncated, and the price row still starts at the same Y on
every card in that row.

ProductCard.tsx is flattened from nested flex divs into 6 direct grid-row
children so each one can be its own subgrid track; the old flex-1 spacer
is replaced by self-end on the button's row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
This commit is contained in:
Marco
2026-08-26 23:49:50 +00:00
parent 8f006b6603
commit 98b3b3b6da
4 changed files with 77 additions and 65 deletions
+8 -4
View File
@@ -129,9 +129,13 @@ export function RelatedProducts({
(opacity: 0) — invisible. Not worth chasing a fix for a
scroll-reveal nicety on a list that mutates; a static grid
renders correctly with no animation risk. */}
{/* No items-start — see ProductGrid.tsx's own comment on why that
silently defeats ProductCard's h-full equal-height design. */}
<div className="grid grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
{/* No items-start, and grid-auto-rows + subgrid on each card — see
ProductGrid.tsx's own comment on why both are needed for
ProductCard's internal rows (price, etc.) to actually line up
across cards, not just overall card height. No RevealItem
wrapper here (see comment above), so the subgrid classes go
straight onto ProductCard's own className prop instead. */}
<div className="grid grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] [grid-auto-rows:auto_auto_auto_auto_auto_minmax(0,1fr)] w-full max-w-[75rem]">
{displayProducts.map((product, i) => (
<ProductCard
key={product.id}
@@ -141,7 +145,7 @@ export function RelatedProducts({
wishlistEnabled={wishlistEnabled}
wishlistRevealOnHover
className={
"sm:col-span-4 " +
"sm:col-span-4 [grid-row:span_6] " +
// Center the row when there are fewer than 3 cards to show
// (e.g. only 1 active product left once the others are
// already in the cart) — only the first card needs an
+51 -48
View File
@@ -54,8 +54,20 @@ export function ProductCard({
const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock;
return (
// 6 direct grid rows (image / header / price / belowPrice / lowstock /
// button) instead of the old flex column — a plain flex-1 spacer only
// pins the button to the bottom of THIS card, it can't make the price
// row start at the same Y as a row sibling whose header block is a
// different height (e.g. a 2-line vs 1-line subline). grid-template-
// rows: subgrid pulls in the row-tracks each caller's grid container
// already declares (see ProductGrid.tsx's own comment) so every row is
// genuinely shared/max'd across the row of cards — dynamic, no
// line-clamp/truncation needed on the subline. gap-3 is used for every
// row gap including after the image (was pt-4/1rem there before —
// 0.25rem less, accepted for one consistent grid gap instead of mixed
// margin/gap spacing).
<div
className={`group bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full transition-transform duration-300 hover:-translate-y-1 ${className}`}
className={`group bg-bg-base border border-border rounded-md overflow-hidden grid [grid-template-rows:subgrid] [grid-row:span_6] gap-3 transition-transform duration-300 hover:-translate-y-1 ${className}`}
>
<div className="relative w-full aspect-[276/210] overflow-hidden">
{/* Link wraps only the image, not the whole header — WishlistButton
@@ -98,55 +110,46 @@ export function ProductCard({
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover={wishlistRevealOnHover} />
)}
</div>
<div className="flex flex-col gap-3 items-start px-5 pb-5 pt-4 w-full flex-1">
<div className="flex flex-col gap-1 items-start w-full">
{product.categories.length > 0 && (
<p className="text-label font-semibold text-text-muted uppercase tracking-wide">{product.categories.join(", ")}</p>
)}
{/* line-clamp-1 on the name and a reserved+clamped 2-line slot
for the subline — without these, a longer name or a subline
that wraps to 2 lines pushes THAT card's price row down
relative to its row siblings; the flex-1 spacer near the
button only re-aligns the button, not the price above it. */}
{product.href ? (
<Link
href={product.href}
className="font-semibold text-h4 text-text-primary w-full line-clamp-1 hover:text-brand transition-colors"
style={{ fontFamily: "var(--font-lora)" }}
>
<ProductName name={product.name} />
</Link>
) : (
<p className="font-semibold text-h4 text-text-primary w-full line-clamp-1" style={{ fontFamily: "var(--font-lora)" }}>
<ProductName name={product.name} />
</p>
)}
<p className="text-body-sm text-text-muted w-full line-clamp-2 min-h-[2.75rem]">{product.subline}</p>
</div>
<p className="flex items-baseline gap-1.5">
{discount !== null && (
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
{belowPrice}
{/* Always rendered, text conditional — reserved height keeps every
card in a row equal height regardless of low-stock status. An
earlier version omitted this for fully-out-of-stock products
(this line can never apply there), on the theory that it was
dead space — but NotifyMeForm's single-row redesign (see its
own comment) now matches AddToCartInlineButton's button height
exactly, which made THIS line the only remaining source of a
mismatch: omitting it made the sold-out card shorter than its
siblings instead of taller. Keeping it unconditional is what
actually gets an exact match now. */}
<p className="min-h-[1.05rem] text-label font-bold text-warning">{anyLowStock ? "Nur noch wenige verfügbar" : null}</p>
{/* flex-1 spacer — pins every card's button to the same Y
regardless of whether the title/category line wraps. */}
<div className="flex-1" />
<div className="flex flex-col gap-1 items-start w-full px-5">
{product.categories.length > 0 && (
<p className="text-label font-semibold text-text-muted uppercase tracking-wide">{product.categories.join(", ")}</p>
)}
{product.href ? (
<Link
href={product.href}
className="font-semibold text-h4 text-text-primary w-full hover:text-brand transition-colors"
style={{ fontFamily: "var(--font-lora)" }}
>
<ProductName name={product.name} />
</Link>
) : (
<p className="font-semibold text-h4 text-text-primary w-full" style={{ fontFamily: "var(--font-lora)" }}>
<ProductName name={product.name} />
</p>
)}
{product.subline && <p className="text-body-sm text-text-muted w-full">{product.subline}</p>}
</div>
<p className="flex items-baseline gap-1.5 px-5">
{discount !== null && (
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
<div className="px-5">{belowPrice}</div>
{/* Always rendered, text conditional — an empty row still occupies
its shared track (0-height unless a row sibling needs it), same
reasoning as the price/header rows above. */}
<p className="text-label font-bold text-warning px-5">{anyLowStock ? "Nur noch wenige verfügbar" : null}</p>
{/* self-end pins the button to the bottom of this row even though
the row itself is minmax(0,1fr) (see grid-auto-rows) and can be
taller than the button — replaces the old flex-1 spacer. */}
<div className="self-end px-5 pb-5 w-full">
<AddToCartInlineButton
id={product.id}
numericId={product.numericId}
@@ -39,17 +39,18 @@ export function MerklisteGrid({
}
return (
// No items-start — see ProductGrid.tsx's own comment on why that
// silently defeats ProductCard's h-full equal-height design.
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
// No items-start, and grid-auto-rows + subgrid on each RevealItem —
// see ProductGrid.tsx's own comment on why both are needed for
// ProductCard's internal rows (price, etc.) to actually line up
// across cards, not just overall card height.
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 [grid-auto-rows:auto_auto_auto_auto_auto_minmax(0,1fr)] w-full">
{visibleEntries.map(({ item, product }) => (
<RevealItem key={`${product.id}-${item.variant}`}>
<RevealItem key={`${product.id}-${item.variant}`} className="grid [grid-template-rows:subgrid] [grid-row:span_6]">
<ProductCard
product={product}
defaultTaxRate={defaultTaxRate}
kleinunternehmer={kleinunternehmer}
wishlistEnabled
className="h-full"
// Already-purchased takes precedence over the default
// Ausverkauft/discount badge — a customer who already bought
// this doesn't need a restock notice, they need to know they
+12 -8
View File
@@ -115,17 +115,22 @@ export async function ProductGrid({
stuck at opacity 0 — the grid going blank/white after
applying a filter. */}
{/* No items-start here (was set before, silently defeating
ProductCard's own h-full + flex-1 spacer, built exactly for
equal-height cards) — default items-stretch is what actually
makes every card in a row match the tallest one, e.g. when
one product's subline wraps to 2 lines and its neighbors'
don't. */}
ProductCard's own h-full, built exactly for equal-height
cards) — default items-stretch is what makes every card in a
row match the tallest one overall. That alone doesn't align
each card's internal rows (e.g. the price sitting right
after a subline that wraps to 2 lines on one card but 1 on
its neighbor) — see ProductCard.tsx's own comment on the
subgrid rows (grid-auto-rows below) that solves that part:
each RevealItem here spans the same 6 row-tracks and passes
them down via grid-template-rows: subgrid, so row heights are
genuinely shared, no fixed line-clamp/truncation needed. */}
<RevealGroup
key={products.map((p) => p.id).join(",")}
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full"
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] [grid-auto-rows:auto_auto_auto_auto_auto_minmax(0,1fr)] w-full"
>
{products.map((product) => (
<RevealItem key={product.id} className="lg:col-span-4">
<RevealItem key={product.id} className="lg:col-span-4 grid [grid-template-rows:subgrid] [grid-row:span_6]">
<ProductCard
product={product}
defaultTaxRate={defaultTaxRate}
@@ -137,7 +142,6 @@ export async function ProductGrid({
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
}
className="h-full"
/>
</RevealItem>
))}