shadimage
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);
}

More from diklein

Browse all components in this library →