Skip to content

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.

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

Props

Arrow
No runtime dependencies
"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>
  );
}
Notes

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.

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 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.

templatelanding pageauction house
New

A 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.

navigationheadernavbar