Skip to content

Plinth Object Card

An object standing on one gold plinth line with a catalogue caption: lot number, Didone title, date, provenance and estimate. The line draws out on hover.

FreeNeoclassicaleditorialminimal
Category
Components
Added
2026-09-26
Deps
none
Updated
2026-09-26

Props

Linked
No runtime dependencies
"use client";

import { useId } from "react";

/**
 * PlinthObjectCard — an object set on a plinth line, with a catalogue
 * caption, the way auction houses and museums present a single piece.
 *
 * The picture is a cut-out photograph standing on one gold hairline (the
 * plinth), with a soft contact shadow and nothing else: no frame, no
 * rounded corners, no drop shadow around the card. The caption below is set
 * like a catalogue entry, left-aligned: the lot number in spaced capitals,
 * the title in a Didone, the date in italic, the provenance in a text serif,
 * then a rule and the estimate. On hover or focus the plinth line draws out
 * to the full width of the picture and "View lot" comes forward. The object
 * never moves.
 *
 * Give it `image` (a transparent cut-out, or a shot on white: it is
 * multiplied into the ground so a white backdrop disappears). Without one it
 * draws a turned vessel in SVG as a stand-in: `vessel` picks the shape and
 * `material` the finish. Pass href to make the whole card one link.
 *
 * Type reads var(--font-didone) and var(--font-text), falling back to
 * system serifs. Needs Tailwind v4 (or v3.4+). No dependencies beyond React.
 */

const DIDONE = 'var(--font-didone, "Bodoni Moda", "Didot", "Bodoni 72", "Bodoni MT", Georgia, serif)';
const TEXT = 'var(--font-text, "Iowan Old Style", "Baskerville", "Libre Baskerville", Georgia, "Times New Roman", serif)';

const TONES = {
  ivory: { ground: "#f4f1ea", well: "#ece7dc", ink: "#14120f", muted: "rgba(20,18,15,0.62)", rule: "rgba(20,18,15,0.18)", shadow: "rgba(20,18,15,0.26)" },
  charcoal: { ground: "#14120f", well: "#1c1a16", ink: "#f4f1ea", muted: "rgba(244,241,234,0.6)", rule: "rgba(244,241,234,0.18)", shadow: "rgba(0,0,0,0.7)" },
} as const;

const HEX = /^#[0-9a-fA-F]{6}$/;

type Vessel = "amphora" | "urn" | "lekythos";
type Material = "black-glaze" | "terracotta" | "marble" | "bronze";

// Half-profiles of turned vessels: [depth from the lip, radius], both as a
// fraction of the drawing's height, lip to foot.
const PROFILES: Record<Vessel, { pts: [number, number][]; handles: string[]; w: number }> = {
  amphora: {
    w: 0.62,
    pts: [[0, 0.1], [0.025, 0.105], [0.05, 0.075], [0.17, 0.07], [0.22, 0.12], [0.3, 0.24], [0.42, 0.29], [0.56, 0.28], [0.7, 0.22], [0.82, 0.13], [0.9, 0.075], [0.945, 0.07], [0.97, 0.11], [1, 0.115]],
    handles: ["M-0.068 0.07 C-0.2 0.06 -0.23 0.14 -0.16 0.27", "M0.068 0.07 C0.2 0.06 0.23 0.14 0.16 0.27"],
  },
  urn: {
    w: 0.86,
    pts: [[0, 0.2], [0.03, 0.21], [0.06, 0.16], [0.12, 0.17], [0.22, 0.3], [0.33, 0.35], [0.46, 0.34], [0.6, 0.27], [0.7, 0.19], [0.76, 0.1], [0.81, 0.095], [0.88, 0.19], [0.95, 0.215], [1, 0.22]],
    handles: ["M-0.27 0.23 C-0.43 0.17 -0.45 0.33 -0.33 0.33", "M0.27 0.23 C0.43 0.17 0.45 0.33 0.33 0.33"],
  },
  lekythos: {
    w: 0.46,
    pts: [[0, 0.07], [0.03, 0.085], [0.05, 0.05], [0.17, 0.045], [0.21, 0.17], [0.27, 0.2], [0.55, 0.19], [0.76, 0.16], [0.86, 0.09], [0.91, 0.06], [0.95, 0.1], [1, 0.105]],
    handles: ["M0.045 0.08 C0.17 0.08 0.2 0.15 0.16 0.22"],
  },
};

// Cylindrical shading across the body, lit from the upper left: [offset, colour].
const FINISH: Record<Material, { stops: [number, string][]; sheen: number; band?: string }> = {
  "black-glaze": {
    stops: [[0, "#0b0a09"], [0.18, "#23201c"], [0.31, "#57504a"], [0.4, "#26221e"], [0.75, "#12100e"], [1, "#070606"]],
    sheen: 0.26,
    band: "#8f4a2c",
  },
  terracotta: {
    stops: [[0, "#4a2716"], [0.2, "#8e4f2f"], [0.33, "#c27b52"], [0.5, "#99563a"], [0.8, "#62331f"], [1, "#351b10"]],
    sheen: 0.12,
    band: "#17120e",
  },
  marble: {
    stops: [[0, "#8d887f"], [0.2, "#cfc9bf"], [0.34, "#f1ede6"], [0.6, "#cbc5ba"], [0.85, "#a29c91"], [1, "#77736b"]],
    sheen: 0.18,
  },
  bronze: {
    stops: [[0, "#1d1b14"], [0.2, "#4d4430"], [0.33, "#8b7a51"], [0.46, "#55492f"], [0.8, "#2a261b"], [1, "#12110c"]],
    sheen: 0.2,
  },
};

/** A smooth closed outline from a half-profile (Catmull-Rom through the points, mirrored). */
function outline(pts: [number, number][]) {
  const right = pts.map(([y, r]) => [r, y] as [number, number]);
  const left = [...pts].reverse().map(([y, r]) => [-r, y] as [number, number]);
  const ring = [...right, ...left];
  const f = (n: number) => n.toFixed(4);
  let d = "M" + f(ring[0][0]) + " " + f(ring[0][1]);
  for (let i = 0; i < ring.length - 1; i++) {
    const p0 = ring[Math.max(0, i - 1)];
    const p1 = ring[i];
    const p2 = ring[i + 1];
    const p3 = ring[Math.min(ring.length - 1, i + 2)];
    // The foot and lip are straight across; everything else curves.
    if (Math.sign(p1[0]) !== Math.sign(p2[0])) {
      d += "L" + f(p2[0]) + " " + f(p2[1]);
      continue;
    }
    const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
    const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
    d += "C" + f(c1[0]) + " " + f(c1[1]) + " " + f(c2[0]) + " " + f(c2[1]) + " " + f(p2[0]) + " " + f(p2[1]);
  }
  return d + "Z";
}

function VesselDrawing({ vessel, material, uid }: { vessel: Vessel; material: Material; uid: string }) {
  // Own keys only, so an unexpected value falls back instead of reading the prototype.
  const p = Object.prototype.hasOwnProperty.call(PROFILES, vessel) ? PROFILES[vessel] : PROFILES.amphora;
  const m = Object.prototype.hasOwnProperty.call(FINISH, material) ? FINISH[material] : FINISH["black-glaze"];
  const body = outline(p.pts);
  const shade = uid + "s";
  const sheen = uid + "h";
  const clip = uid + "c";
  const depth = uid + "d";
  const handleColour = m.stops[1][1];
  return (
    <svg viewBox={-p.w / 2 - 0.02 + " -0.01 " + (p.w + 0.04) + " 1.02"} className="h-full w-auto overflow-visible" aria-hidden>
      <defs>
        <linearGradient id={shade} x1="0" x2="1" y1="0" y2="0">
          {m.stops.map(([o, c]) => (
            <stop key={o} offset={o} stopColor={c} />
          ))}
        </linearGradient>
        {/* The glaze's highlight: a narrow vertical streak on the lit side. */}
        <linearGradient id={sheen} x1="0" x2="1" y1="0" y2="0">
          <stop offset="0.22" stopColor="#fff" stopOpacity={0} />
          <stop offset="0.3" stopColor="#fff" stopOpacity={m.sheen} />
          <stop offset="0.39" stopColor="#fff" stopOpacity={0} />
        </linearGradient>
        {/* Shade under the shoulder and toward the foot. */}
        <linearGradient id={depth} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#000" stopOpacity={0.2} />
          <stop offset="0.3" stopColor="#000" stopOpacity={0} />
          <stop offset="0.7" stopColor="#000" stopOpacity={0.05} />
          <stop offset="1" stopColor="#000" stopOpacity={0.35} />
        </linearGradient>
        <clipPath id={clip}>
          <path d={body} />
        </clipPath>
      </defs>
      {p.handles.map((h, i) => (
        <path key={i} d={h} fill="none" stroke={handleColour} strokeWidth={0.024} strokeLinecap="round" />
      ))}
      <path d={body} fill={"url(#" + shade + ")"} />
      <g clipPath={"url(#" + clip + ")"}>
        {m.band && (
          <>
            <rect x={-0.5} y={0.33} width={1} height={0.012} fill={m.band} opacity={0.9} />
            <rect x={-0.5} y={0.62} width={1} height={0.006} fill={m.band} opacity={0.9} />
          </>
        )}
        <rect x={-0.5} y={0} width={1} height={1} fill={"url(#" + depth + ")"} />
        <rect x={-p.w / 2} y={0} width={p.w} height={1} fill={"url(#" + sheen + ")"} />
      </g>
    </svg>
  );
}

/** Images: strip tabs and newlines, refuse backslashes, allow http(s) and same-site paths. */
function safeSrc(raw: string) {
  // A URL parser drops tabs and newlines anywhere, and control characters at
  // either end, before it reads the scheme: do the same before judging it.
  let v = raw.replace(/[\t\n\r]/g, "");
  let a = 0;
  let b = v.length;
  while (a < b && v.charCodeAt(a) <= 32) a++;
  while (b > a && v.charCodeAt(b - 1) <= 32) b--;
  v = v.slice(a, b).trim();
  if (!v || v.includes("\\")) return "";
  if (/^https?:\/\//i.test(v)) return v;
  if (!/^[^/?#]*:/.test(v) && !/^\/\//.test(v)) return v;
  return "";
}

/** Links: strip tabs and newlines, refuse backslashes, allow http(s), mailto, tel and same-site paths. */
function safeHref(raw: string) {
  const v = raw.replace(/[\t\n\r]/g, "").trim();
  if (!v || v.includes("\\")) return "#";
  if (/^(https?:|mailto:|tel:)/i.test(v)) return v;
  if (/^[/#?]/.test(v) && !/^\/\//.test(v)) return v;
  return "#";
}

export type PlinthObjectCardProps = {
  /** "Lot 214", an accession number, a reference. */
  lot: string;
  title: string;
  /** "Circa 480 BC", "Signed and dated 1874". */
  date?: string;
  provenance?: string;
  /** Label for the figure below the rule. */
  estimateLabel?: string;
  /** "£6,000 – 8,000", "Price on request". */
  estimate?: string;
  href?: string;
  /** Call to action shown with the estimate. */
  action?: string;
  /** A cut-out or a shot on white; drawn standing on the plinth line. */
  image?: { src: string; alt: string };
  /** The stand-in drawn when there is no image. */
  vessel?: Vessel;
  material?: Material;
  tone?: "ivory" | "charcoal";
  /** The plinth line's metal, as #rrggbb. */
  metal?: string;
  /** Heading level for the title, to fit the page outline. */
  as?: "h2" | "h3" | "h4";
  className?: string;
};

export function PlinthObjectCard({
  lot,
  title,
  date,
  provenance,
  estimateLabel = "Estimate",
  estimate,
  href,
  action = "View lot",
  image,
  vessel = "amphora",
  material = "black-glaze",
  tone = "ivory",
  metal = "#b8964f",
  as = "h3",
  className = "",
}: PlinthObjectCardProps) {
  // The heading level is checked at run time: a value from a CMS must never
  // become an arbitrary element.
  const Heading = as === "h2" || as === "h4" ? as : "h3";
  const uid = "poc" + useId().replace(/[^a-zA-Z0-9]/g, "");
  const t = tone === "charcoal" ? TONES.charcoal : TONES.ivory;
  const gold = HEX.test(metal) ? metal : "#b8964f";
  const src = image ? safeSrc(image.src) : "";
  const ease = "duration-[600ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none";

  const body = (
    <>
      <div className="relative aspect-square overflow-hidden" style={{ background: t.well }}>
        {/* The contact shadow: the one soft shadow in the card. */}
        <div
          aria-hidden
          className="absolute bottom-[16%] left-1/2 h-4 w-[46%] -translate-x-1/2 translate-y-1/2"
          style={{ background: "radial-gradient(closest-side, " + t.shadow + ", transparent)" }}
        />
        <div className="absolute inset-x-0 bottom-[16%] top-[12%] flex items-end justify-center">
          {src ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img
              src={src}
              alt={image!.alt}
              loading="lazy"
              decoding="async"
              className="h-full w-auto max-w-[80%] object-contain object-bottom"
              style={{ mixBlendMode: tone === "ivory" ? "multiply" : undefined }}
            />
          ) : (
            <VesselDrawing vessel={vessel} material={material} uid={uid} />
          )}
        </div>
        <span
          aria-hidden
          className={"absolute inset-x-[6%] bottom-[16%] h-px origin-center scale-x-[0.62] opacity-[0.45] transition-[transform,opacity] group-hover:scale-x-100 group-hover:opacity-100 group-focus-visible:scale-x-100 group-focus-visible:opacity-100 " + ease}
          style={{ background: gold }}
        />
      </div>

      <div className="flex flex-1 flex-col pt-5" style={{ color: t.ink }}>
        <p className="text-[11px] uppercase tracking-[0.22em]" style={{ fontFamily: DIDONE }}>
          {lot}
        </p>
        <Heading className="mt-2.5 text-[21px] font-normal leading-[1.2] tracking-[-0.005em]" style={{ fontFamily: DIDONE }}>
          {title}
        </Heading>
        {date && (
          <p className="mt-1 text-[15px] italic" style={{ fontFamily: DIDONE, color: t.muted }}>
            {date}
          </p>
        )}
        {provenance && (
          <p className="mt-3 line-clamp-2 text-[13.5px] leading-[1.55]" style={{ fontFamily: TEXT, color: t.muted }}>
            {provenance}
          </p>
        )}
        {(estimate || href) && (
          <div className="mt-auto pt-5">
            <div className="flex items-baseline justify-between gap-4 pt-3.5" style={{ borderTop: "1px solid " + t.rule }}>
              {estimate ? (
                <p className="flex items-baseline gap-3">
                  <span className="text-[10.5px] uppercase tracking-[0.2em]" style={{ fontFamily: DIDONE, color: t.muted }}>
                    {estimateLabel}
                  </span>
                  <span className="text-[15px] tabular-nums" style={{ fontFamily: TEXT }}>
                    {estimate}
                  </span>
                </p>
              ) : (
                <span />
              )}
              {href && (
                <span
                  aria-hidden
                  className={"flex items-center gap-2 text-[10.5px] uppercase tracking-[0.2em] opacity-60 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 " + ease}
                  style={{ fontFamily: DIDONE }}
                >
                  {action}
                  <svg
                    width="18"
                    height="8"
                    viewBox="0 0 18 8"
                    fill="none"
                    className={"transition-transform group-hover:translate-x-[3px] group-focus-visible:translate-x-[3px] " + ease}
                  >
                    <path d="M0 4H17M13.5 0.5L17 4L13.5 7.5" stroke="currentColor" strokeWidth="1" />
                  </svg>
                </span>
              )}
            </div>
          </div>
        )}
      </div>
    </>
  );

  if (href) {
    return (
      <a
        href={safeHref(href)}
        className={"group flex flex-col outline-offset-[6px] focus-visible:outline focus-visible:outline-1 " + className}
        style={{ outlineColor: t.ink }}
      >
        <article className="flex h-full flex-col">{body}</article>
      </a>
    );
  }
  return <article className={"group flex flex-col " + className}>{body}</article>;
}
Notes

About this component

Auction houses and museums present a piece the same way: the object alone on a plain ground, and a caption set like a catalogue entry. This card does that. The picture is a cut-out photograph standing on a single gold hairline, the plinth, with a soft contact shadow and nothing else: no frame, no rounded corners, no shadow around the card. Below it the caption runs left-aligned, as in a printed catalogue: the lot number in spaced capitals, the title in a Didone, the date in italic, the provenance in a text serif, then a rule and the estimate in tabular figures. Pointing at the card draws the plinth line out to the width of the picture and brings the call to action forward; the object never moves. A photograph shot on white is multiplied into the ground, so the backdrop disappears. Without an image the card draws a turned vessel in SVG, in black glaze, terracotta, marble or bronze.

Curator’s note

Part of the Neoclassical kit, drop 3, phase 7: the kit's card. Free: one component with CSS hover states. It needs a real cut-out photograph per object; the demo draws stand-in vessels in SVG.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

Featured
NewPro

A centred Didone headline under a segmental arch drawn in one gold hairline: it rises from both bases, meets at a keystone, and catches a raking light under the pointer.

heroneoclassicaldidone
NewPro

A feature section whose picture sits behind two reeded panels meeting on a gold seam; they part from the centre as you scroll, then the catalogue caption comes forward.

sectionscroll animationreveal
New

Engraved Numerals

text effects

Prices, dates and years cut in stroke by stroke: a gold hairline traces every character's outline in a Didone, then the figure fills with ink as the line fades.

text effectnumberssvg