diklein
componentdk-product-cardsource: ok · refreshed
DKProductCard
A product card for MDX blogs. Title, description, image, status strip, and note-style bodies. Pairs with the @diklein/dkproductcard remark plugin, which turns bare Amazon URLs into cards automatically.
npx shadcn add "https://diklein.com/r/dk-product-card.json"free
Where it goes
statsblog-list
Interaction
animated-counter
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@diklein/dkproductcard
Files2 · source cached 10/8/2026, 6:49:21 AM
registry/dk-product-card/product-card.tsx
import type { ReactNode } from 'react'
export interface DKProductCardProps {
/** Product name, set in a medium weight above the description. */
title?: string
/** One-line supporting copy, muted. */
description?: string
/** Where the card links. Omit it (and asin) for a card that is not clickable. */
href?: string
/** Amazon ASIN. Builds the href (amazon.com/dp/ASIN) and switches the label to "Amazon". */
asin?: string
/** Link label. Defaults to "Amazon" for Amazon links, "View" otherwise. */
label?: string
/** Product image URL, rendered in a fixed square slot on the left. */
image?: string
/** Alt text for the product image. Falls back to the title. */
imageAlt?: string
/** Sugar for strip="Retired". */
retired?: boolean
/** Status-strip text across the top of the card, e.g. "Retired" or "Updated". */
strip?: string
/** Rich body content for note-style cards (a strip plus a sentence, no image or title).
* Markdown links inside survive from MDX and pick up the accent treatment. */
children?: ReactNode
/** Render the product image yourself (e.g. with next/image). Apply ctx.className
* and ctx.alt to the element you return. */
renderImage?: (src: string, ctx: { alt: string; className: string }) => ReactNode
}
export function DKProductCard({
title,
description,
href,
asin,
label,
image,
imageAlt,
retired,
strip,
children,
renderImage,
}: DKProductCardProps) {
const url = href ?? (asin ? `https://www.amazon.com/dp/${asin}` : undefined)
const isAmazon = !!asin || (url?.includes('amazon.com') ?? false)
const displayLabel = label ?? (isAmazon ? 'Amazon' : 'View')
/* Some products cannot be linked (discontinued, page gone). A card with a dead
"View" pointing at "#" is worse than a card that simply does not claim to be
clickable, so with no href and no asin this renders as a plain div: no link,
no label, none of the hover affordances that would promise one. */
const Shell = url ? 'a' : 'div'
const shellProps = url ? { href: url, target: '_blank', rel: 'noopener noreferrer' } : {}
const stripText = strip ?? (retired ? 'Retired' : null)
const isNote = !image && !title && !!children
const alt = imageAlt ?? title ?? ''
return (
<div className="dkpc-scope dkpc-root">
<Shell {...shellProps} className={`dkpc-card${url ? ' dkpc-linked' : ''}`}>
{/* The state belongs to the whole card, not to the title, so it reads better
as a band across the top than as a pill floating inside the content. */}
{stripText && <div className="dkpc-strip">{stripText}</div>}
{/* Note-style cards tuck the body under the strip with matching padding, so
the sentence left-aligns with the strip label. Product cards keep the
roomier frame. */}
<div className={`dkpc-row${isNote ? ' dkpc-row-note' : ''}`}>
{image && (
<div className="dkpc-image-slot">
{renderImage ? (
renderImage(image, { alt, className: 'dkpc-image' })
) : (
<img src={image} alt={alt} className="dkpc-image" loading="lazy" />
)}
</div>
)}
<div className="dkpc-content">
{title && <p className="dkpc-title" dir="auto">{title}</p>}
{children && <div className="dkpc-body" dir="auto">{children}</div>}
{description && <p className="dkpc-desc" dir="auto">{description}</p>}
{url && (
<p className="dkpc-label">
<span className="dkpc-label-text">{displayLabel}</span>
</p>
)}
</div>
</div>
</Shell>
</div>
)
}
registry/dk-product-card/dk-product-card.css
/* DKProductCard — theme tokens + every rule the card needs. No Tailwind required.
*
* Every color routes through a --dkpc-* variable so theming is one override away:
*
* .dkpc-scope { --dkpc-accent: rebeccapurple; }
*
* Dark mode follows the shadcn convention (a `.dark` class on <html>) and falls back to
* prefers-color-scheme when no theme class is in play. Fonts default to the system stack;
* point --dkpc-font-sans at your own face to match your site. */
.dkpc-scope {
--dkpc-fg: #1a1a1a;
--dkpc-muted: #6b6b6b;
--dkpc-surface: rgb(0 0 0 / 0.05);
--dkpc-accent: #e03131;
--dkpc-outline: rgb(0 0 0 / 0.1);
--dkpc-font-sans: ui-sans-serif, system-ui, sans-serif;
}
.dark .dkpc-scope {
--dkpc-fg: #ededed;
--dkpc-muted: #9a9a9a;
--dkpc-surface: rgb(255 255 255 / 0.08);
--dkpc-outline: rgb(255 255 255 / 0.1);
}
/* No theme class anywhere up the tree? Track the OS. (:where() keeps specificity at zero
so the .dark rule above always wins when present.) */
@media (prefers-color-scheme: dark) {
:where(html:not(.light) .dkpc-scope) {
--dkpc-fg: #ededed;
--dkpc-muted: #9a9a9a;
--dkpc-surface: rgb(255 255 255 / 0.08);
--dkpc-outline: rgb(255 255 255 / 0.1);
}
}
.dkpc-root {
font-family: var(--dkpc-font-sans);
color: var(--dkpc-fg);
margin-bottom: 2rem;
/* As a flex/grid item the card cooperates with the layout and shrinks;
without this its min-content width can blow out a consumer's flex row. */
min-width: 0;
}
/* A run of consecutive cards reads as one group: tighten the gap between them. */
.dkpc-root:has(+ .dkpc-root) {
margin-bottom: 1.5rem;
}
.dkpc-card {
display: block;
overflow: hidden;
border: 1px solid var(--dkpc-outline);
color: inherit;
text-decoration: none;
}
.dkpc-linked {
transition: border-color 0.15s ease;
}
.dkpc-linked:hover,
.dkpc-linked:active {
border-color: var(--dkpc-muted);
}
/* The status strip: a sliver of chrome that labels the card before its content does.
Foreground color, not muted: it is a label the eye should catch, and max contrast
holds up in both themes. */
.dkpc-strip {
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--dkpc-fg);
background: var(--dkpc-surface);
padding: 0.5rem 1.5rem 0.5rem 1rem;
overflow-wrap: break-word;
}
.dkpc-row {
display: flex;
align-items: flex-start;
gap: 2rem;
padding: 1.5rem;
}
/* Note-style cards (strip + a sentence): tighter padding, matched to the strip's
left inset so the sentence aligns with the strip label. */
.dkpc-row-note {
padding: 0.75rem 1rem 1rem;
}
/* min-width: 0 lets the text column shrink below an unbreakable token's width —
without it, flex min-content sizing keeps the column as wide as the longest
word and overflow-wrap never gets the chance to break it. */
.dkpc-content {
min-width: 0;
flex: 1;
}
.dkpc-image-slot {
position: relative;
flex-shrink: 0;
width: 6rem;
/* Stretch to the text column instead of a fixed square, so the image never
makes the card taller than its content; the min-height is a floor for
near-empty cards. */
align-self: stretch;
min-height: 6rem;
}
@media (min-width: 900px) {
.dkpc-image-slot {
width: 9rem;
}
}
.dkpc-image {
width: 100%;
height: 100%;
object-fit: contain;
/* Top-aligned with the title, not centered in the slot: a wide product shot
otherwise floats to the middle of the square and reads as a spacing bug. */
object-position: top;
}
.dkpc-title {
margin: 0;
font-weight: 600;
line-height: 1.375;
overflow-wrap: break-word;
}
.dkpc-body {
line-height: 1.6;
overflow-wrap: break-word;
}
.dkpc-body p {
margin: 0;
}
/* MDX can hand the body over as bare inline content (no <p> wrapper), which prose
selectors like `article p a` never match. Style links on the container instead,
so a link in a note card cannot silently render as plain text. */
.dkpc-body a {
color: var(--dkpc-accent);
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-color: color-mix(in oklch, var(--dkpc-accent) 30%, transparent);
transition: text-decoration-color 0.15s ease;
}
.dkpc-body a:hover {
text-decoration-color: var(--dkpc-accent);
}
.dkpc-desc {
margin: 0.5rem 0 0;
font-size: 1.125rem;
color: var(--dkpc-muted);
overflow-wrap: break-word;
}
.dkpc-label {
margin: 1rem 0 0;
font-size: 1.125rem;
color: var(--dkpc-accent);
}
.dkpc-label-text {
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-color: color-mix(in oklch, var(--dkpc-accent) 30%, transparent);
transition: text-decoration-color 0.15s ease;
}
.dkpc-linked:hover .dkpc-label-text {
text-decoration-color: var(--dkpc-accent);
}