Search

Jump to any page.

Marketing

Product Card

Full-bleed product tile with serif name and a Meet cue.

pnpm dlx shadcn@latest add @paragon/product-card
Brings in@paragon/eyebrow@paragon/panel@base-ui/reactlucide-reactcn
registry/paragon/components/product-card.tsx
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { ArrowRight } from "lucide-react"
import { cn } from "cn"

import { Eyebrow } from "@/registry/paragon/components/eyebrow"
import { panelVariants } from "@/registry/paragon/components/panel"

/**
 * Full-bleed product tile: eyebrow up top, serif product name, one-line
 * promise and a "Meet …" cue at the bottom. Pass `image` for photography,
 * otherwise the `tone` gradient is used.
 */
function ProductCard({
  eyebrow,
  name,
  description,
  cta,
  image,
  tone = "night",
  className,
  render,
  ...props
}: Omit<useRender.ComponentProps<"a">, "children"> & {
  eyebrow?: React.ReactNode
  name: React.ReactNode
  description?: React.ReactNode
  cta?: React.ReactNode
  /** Background image URL. A night scrim keeps the text legible. */
  image?: string
  tone?: "night" | "dusk" | "haze"
}) {
  return useRender({
    defaultTagName: "a",
    props: mergeProps<"a">(
      {
        className: cn(
          panelVariants({ tone, padding: "none" }),
          "group/product @container dark flex min-h-[26rem] flex-col justify-between p-6 text-white outline-none focus-visible:ring-3 focus-visible:ring-ring/50 md:p-8",
          className
        ),
        children: (
          <>
            {image ? (
              <>
                <span
                  aria-hidden="true"
                  className="absolute inset-0 -z-20 bg-cover bg-center transition-transform duration-700 ease-paragon group-hover/product:scale-[1.03]"
                  style={{ backgroundImage: `url(${image})` }}
                />
                <span
                  aria-hidden="true"
                  className="absolute inset-0 -z-10 bg-[linear-gradient(180deg,color-mix(in_oklab,var(--night),transparent_70%)_0%,transparent_35%,color-mix(in_oklab,var(--night),transparent_35%)_100%)]"
                />
              </>
            ) : null}
            {eyebrow ? <Eyebrow tone="inverse">{eyebrow}</Eyebrow> : <span />}
            <div className="flex flex-col gap-3">
              <h3 className="font-serif text-title font-normal [font-size:min(3.25rem,13cqi)] break-words">{name}</h3>
              {description ? (
                <p className="max-w-sm text-lead text-white/80">{description}</p>
              ) : null}
              {cta ? (
                <span className="mt-2 inline-flex w-fit items-center gap-2 border-b border-white/60 pb-0.5 text-lead font-medium transition-colors group-hover/product:border-white">
                  {cta}
                  <ArrowRight
                    aria-hidden="true"
                    strokeWidth={1.75}
                    className="size-[1.05em] transition-transform duration-300 ease-paragon group-hover/product:translate-x-0.5 rtl:-scale-x-100"
                  />
                </span>
              ) : null}
            </div>
          </>
        ),
      },
      props
    ),
    render,
    state: { slot: "product-card", tone },
  })
}

export { ProductCard }