Engraved Rule Button
A heritage button in spaced Didone capitals: on hover a gold hairline engraves itself under the label from the centre out. No fill, no scale, no spring.
- Category
- Components
- Added
- 2026-09-26
- Deps
- none
- Updated
- 2026-09-26
Props
"use client";
import type { MouseEventHandler, ReactNode } from "react";
/**
* EngravedRuleButton — a heritage button: spaced capitals in a Didone, and a
* gold hairline that engraves itself under the label when you point at it.
*
* Nothing fills, nothing scales and nothing springs. On hover or keyboard
* focus a one-pixel rule in the metal draws outward from the centre over
* 600ms, with a one-pixel highlight under it so it reads as cut into the
* surface rather than drawn on top. The optional arrow slides three pixels.
*
* Three registers:
* - "rule": the label and a quiet ink rule, for secondary actions and links
* in running text;
* - "framed": a one-pixel ink frame, square corners, for the main action on
* a page;
* - "solid": filled with ink, for the one transactional action (bid, book,
* pay). The metal is never the fill.
*
* Renders a link when given href, otherwise a button. Type reads
* var(--font-didone) and falls back to system Didones, then Georgia.
*
* 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 TONES = {
ivory: { ink: "#14120f", ground: "#f4f1ea", quiet: "rgba(20,18,15,0.24)", cut: "rgba(255,255,255,0.9)" },
charcoal: { ink: "#f4f1ea", ground: "#14120f", quiet: "rgba(244,241,234,0.26)", cut: "rgba(0,0,0,0.6)" },
} as const;
const HEX = /^#[0-9a-fA-F]{6}$/;
/** 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 EngravedRuleButtonProps = {
children?: ReactNode;
/** Renders a link. */
href?: string;
variant?: "rule" | "framed" | "solid";
size?: "md" | "sm";
/** A fine arrow after the label. */
arrow?: boolean;
/** The ground the button sits on. */
tone?: "ivory" | "charcoal";
/** The engraved rule's metal, as #rrggbb. */
metal?: string;
type?: "button" | "submit" | "reset";
disabled?: boolean;
onClick?: MouseEventHandler<HTMLElement>;
className?: string;
};
export function EngravedRuleButton({
children = "View the catalogue",
href,
variant = "framed",
size = "md",
arrow = false,
tone = "ivory",
metal = "#b8964f",
type = "button",
disabled = false,
onClick,
className = "",
}: EngravedRuleButtonProps) {
const t = tone === "charcoal" ? TONES.charcoal : TONES.ivory;
const gold = HEX.test(metal) ? metal : "#b8964f";
const solid = variant === "solid";
// On an ink fill, an ink metal (the monochrome register) engraves in the ground colour.
const cut = solid && gold.toLowerCase() === t.ink ? t.ground : gold;
const framed = variant === "framed";
const pad = variant === "rule" ? "" : size === "sm" ? "px-5" : "px-7";
const inset = variant === "rule" ? "inset-x-0" : size === "sm" ? "inset-x-3.5" : "inset-x-5";
const bottom = variant === "rule" ? (size === "sm" ? "bottom-[9px]" : "bottom-[11px]") : size === "sm" ? "bottom-[8px]" : "bottom-[10px]";
const cls =
"group relative inline-flex select-none items-center justify-center gap-3 whitespace-nowrap uppercase outline-offset-4 " +
"transition-opacity duration-300 focus-visible:outline focus-visible:outline-1 active:opacity-80 " +
"disabled:pointer-events-none disabled:opacity-40 " +
(size === "sm" ? "h-10 text-[10.5px] tracking-[0.2em] " : "h-12 text-[11.5px] tracking-[0.2em] ") +
pad +
" " +
className;
const style = {
fontFamily: DIDONE,
color: solid ? t.ground : t.ink,
background: solid ? t.ink : undefined,
border: framed ? "1px solid " + t.ink : undefined,
outlineColor: t.ink,
};
const inner = (
<>
<span className="relative">{children}</span>
{arrow && (
<svg
aria-hidden
width="22"
height="8"
viewBox="0 0 22 8"
fill="none"
className="shrink-0 transition-transform duration-[600ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:translate-x-[3px] group-focus-visible:translate-x-[3px] motion-reduce:transition-none"
>
<path d="M0 4H21M17.5 0.5L21 4L17.5 7.5" stroke="currentColor" strokeWidth="1" />
</svg>
)}
{variant === "rule" && <span aria-hidden className={"pointer-events-none absolute h-px " + inset + " " + bottom} style={{ background: t.quiet }} />}
<span
aria-hidden
className={
"pointer-events-none absolute h-px origin-center scale-x-0 transition-transform duration-[600ms] ease-[cubic-bezier(0.22,1,0.36,1)] " +
"group-hover:scale-x-100 group-focus-visible:scale-x-100 motion-reduce:transition-none " +
inset +
" " +
bottom
}
// The engraving: the metal, with a one-pixel cut edge under it.
style={{ background: cut, boxShadow: "0 1px 0 " + (solid ? "rgba(0,0,0,0.35)" : t.cut) }}
/>
</>
);
if (href && !disabled) {
return (
<a href={safeHref(href)} onClick={onClick} className={cls} style={style}>
{inner}
</a>
);
}
return (
<button type={type} disabled={disabled} onClick={onClick} className={cls} style={style}>
{inner}
</button>
);
}
About this component
Heritage sites never fill a button with gold and never let it bounce. This one is set in spaced Didone capitals, and its only movement is a one-pixel rule in the metal that engraves itself under the label, drawing outward from the centre over 600 milliseconds when you point at it or tab to it. A one-pixel cut edge under the rule makes it read as incised rather than drawn on top. It comes in three registers: a plain rule for secondary actions and links in running text, a square one-pixel frame for the main action on a page, and a solid ink button for the single transactional action, such as a bid or a booking. An optional fine arrow slides three pixels. It renders a link when given href and a native button otherwise, works on ivory or charcoal, and takes gold or burgundy.
Curator’s note
Part of the Neoclassical kit, drop 3, phase 7: the kit's primary control. Free: one element and CSS transitions.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Hairline Arch Hero
heroesA 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.
Heritage House Template
templatesA complete page for an auction house, museum or maison, built from the Neoclassical kit: arch hero, lots on plinth lines, a curtain-parting feature, an engraved colonnade of results and a pediment footer.
Insignia Corner Nav
componentsA heritage header: the house's insignia worn small in a gold roundel in one corner, sections centred in spaced capitals with drawn underlines, and a Didone menu sheet on phones.