Lot 212
An Attic black-glazed neck amphora
Circa 480 BC
Provenance: private collection, Lausanne, acquired before 1970.
Estimate£6,000 – 8,000
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.
Lot 212
Circa 480 BC
Provenance: private collection, Lausanne, acquired before 1970.
Estimate£6,000 – 8,000
Lot 213
1st century AD
Provenance: a European noble collection, recorded by 1932.
Estimate£18,000 – 25,000
Lot 214
Circa 340 BC
Provenance: an English private collection, by descent.
Estimate£2,500 – 3,500
"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>;
}
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.
Matched on shared tags and category — the entries most likely to be used alongside this one.
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.
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.
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.