Skip to content

Laurel Ornament Set

Four classical ornaments drawn in code at one stroke weight: a laurel wreath, a rule with a centre device, a concave-cornered cartouche and a frame with corner blocks. Each can trace itself once.

FreeNeoclassicaleditorialminimal
Category
Assets
Added
2026-09-26
Deps
none
Updated
2026-09-26
Preview
Est.1831
Laurel
Rule · lozenge
Rule · points
Rule · leaves
LotNo. 38
Cartouche

Plate IV

The ornaments of the house

Frame

Props

9
140
Draw on view
Replay
No runtime dependencies
"use client";

import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";

/**
 * LaurelOrnamentSet — four classical ornaments drawn in code with one stroke
 * weight: a laurel wreath, a rule with a centre device, a cartouche and a
 * frame with corner blocks.
 *
 * Every line is a one-pixel stroke that stays one pixel at any size
 * (a stroke width scaled by the viewBox, or paths computed from the measured
 * box), so the ornaments sit with the kit's hairlines instead of
 * thickening into clip-art as they grow. Each can draw itself once when it comes into view:
 * the stroke traces over 1400ms and then holds. Nothing loops.
 *
 * - Laurel: two branches of lanceolate leaves on C-curves, alternating,
 *   shrinking toward the tips, crossed at the foot. Leaves, spread and size
 *   are props; children sit in the middle (a year, a monogram).
 * - OrnamentRule: a rule that fills its width with a device at the centre:
 *   a lozenge, three points or a pair of leaves.
 * - Cartouche: a panel with concave corners, doubled, around its children.
 * - OrnamentFrame: a doubled frame with square blocks at the corners.
 *
 * Use one per page, where it earns its place. Repeated as a divider, an
 * ornament turns into wallpaper. Colours default to the kit's gold.
 * Needs Tailwind v4 (or v3.4+). No dependencies beyond React.
 */

const HEX = /^#[0-9a-fA-F]{6}$/;
const EASE = "cubic-bezier(0.22,1,0.36,1)";

// Static rules only: nothing a prop controls is written into this sheet.
const CSS = [
  "@keyframes los-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }",
  "@keyframes los-grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }",
  ".los-armed .los-s { stroke-dasharray: 1; stroke-dashoffset: 1; }",
  ".los-play .los-s { stroke-dasharray: 1; animation: los-draw 1400ms " + EASE + " both; }",
  ".los-armed .los-g { transform: scaleX(0); }",
  ".los-play .los-g { animation: los-grow 900ms " + EASE + " both; }",
  "@media (scripting: none) { .los-armed .los-s { stroke-dashoffset: 0; } .los-armed .los-g { transform: none; } }",
  "@media (prefers-reduced-motion: reduce) { .los-armed .los-s, .los-play .los-s { animation: none; stroke-dashoffset: 0; } .los-armed .los-g, .los-play .los-g { animation: none; transform: none; } }",
].join("\n");

function colour(c: string | undefined) {
  return c && HEX.test(c) ? c : "#b8964f";
}

/**
 * True once the given share of the element is on screen, or as much of it as
 * the view can hold. isIntersecting alone is true at the first visible pixel.
 */
function seen(e: IntersectionObserverEntry, share: number) {
  if (!e.isIntersecting) return false;
  const rootH = e.rootBounds ? e.rootBounds.height : window.innerHeight;
  return e.intersectionRatio >= share - 0.01 || e.intersectionRect.height >= rootH * share - 1;
}

/**
 * "armed" until the element is half on screen (once), then "play"; "still"
 * when drawing is off. Never set synchronously inside the effect body.
 */
function useDraw<T extends HTMLElement>(draw: boolean) {
  const ref = useRef<T>(null);
  const [state, setState] = useState<"armed" | "play">("armed");
  useEffect(() => {
    const el = ref.current;
    if (!draw || !el) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      Promise.resolve().then(() => setState("play"));
      return;
    }
    const io = new IntersectionObserver(
      (entries) => {
        if (seen(entries[entries.length - 1], 0.5)) {
          setState("play");
          io.disconnect();
        }
      },
      { threshold: 0.5 },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [draw]);
  return { ref, cls: draw ? (state === "play" ? "los-play" : "los-armed") : "" };
}

/** The one stylesheet the set needs; render it once per page, or let each ornament carry it (identical copies are harmless). */
export function OrnamentStyles() {
  return <style>{CSS}</style>;
}

// ---- Laurel -----------------------------------------------------------------

type LeafSpec = { d: string };

function laurelLeaves(leaves: number, spread: number): { stem: string; leaves: LeafSpec[] } {
  // One branch in a 200x200 box centred on (100, 100), radius 76, rising
  // from just past the foot to the upper left. The other is its mirror.
  const cx = 100;
  const cy = 100;
  const R = 76;
  const a0 = (100 * Math.PI) / 180;
  const a1 = ((100 + Math.max(80, Math.min(180, spread))) * Math.PI) / 180;
  const pt = (a: number) => [cx + R * Math.cos(a), cy + R * Math.sin(a)];
  const f = (n: number) => n.toFixed(2);
  const [sx, sy] = pt(a0);
  const [ex, ey] = pt(a1);
  // A short tail runs on past the foot, so the two stems cross there.
  const qx = sx + Math.sin(a0) * 17;
  const qy = sy - Math.cos(a0) * 17;
  const stem = "M" + f(qx) + " " + f(qy) + "L" + f(sx) + " " + f(sy) + "A" + R + " " + R + " 0 0 1 " + f(ex) + " " + f(ey);
  const out: LeafSpec[] = [];
  const n = Math.max(4, Math.min(16, Math.round(leaves)));
  for (let i = 0; i <= n; i++) {
    const t = i / n;
    const a = a0 + (a1 - a0) * t;
    const [px, py] = pt(a);
    // Tangent toward the tip (the arc runs clockwise on screen here).
    const tx = -Math.sin(a);
    const ty = Math.cos(a);
    const base = Math.atan2(ty, tx);
    const L = 26 * (1 - 0.42 * t);
    const W = L * 0.3;
    const sides = i === n ? [0] : [1, -1];
    for (const side of sides) {
      // Alternate the pairs a little along the stem, and tilt the leaves out
      // from it, outer side more than inner.
      const tilt = side === 0 ? 0 : side * (side > 0 ? 0.62 : 0.5);
      const ang = base + tilt;
      const ux = Math.cos(ang);
      const uy = Math.sin(ang);
      const vx = -uy;
      const vy = ux;
      const shift = side < 0 ? 0.35 : 0;
      const bx = px + tx * L * shift * 0.3;
      const by = py + ty * L * shift * 0.3;
      const tipx = bx + ux * L;
      const tipy = by + uy * L;
      const c1x = bx + ux * L * 0.45 + vx * W;
      const c1y = by + uy * L * 0.45 + vy * W;
      const c2x = bx + ux * L * 0.45 - vx * W;
      const c2y = by + uy * L * 0.45 - vy * W;
      out.push({
        d: "M" + f(bx) + " " + f(by) + "Q" + f(c1x) + " " + f(c1y) + " " + f(tipx) + " " + f(tipy) + "Q" + f(c2x) + " " + f(c2y) + " " + f(bx) + " " + f(by) + "Z",
      });
    }
  }
  return { stem, leaves: out };
}

export type LaurelProps = {
  /** Leaf pairs per branch, 4 to 16. */
  leaves?: number;
  /** How far each branch climbs, in degrees of arc, 80 to 180. */
  spread?: number;
  /** Rendered size in CSS pixels (square). */
  size?: number;
  color?: string;
  /** Trace the strokes once when half of it is on screen. */
  draw?: boolean;
  /** An accessible name; without one the ornament is hidden from assistive technology. */
  title?: string;
  children?: ReactNode;
  className?: string;
};

export function Laurel({ leaves = 9, spread = 140, size = 200, color, draw = false, title, children, className = "" }: LaurelProps) {
  const c = colour(color);
  const { ref, cls } = useDraw<HTMLDivElement>(draw);
  const { stem, leaves: shapes } = laurelLeaves(leaves, spread);
  const branch = (mirror: boolean) => (
    <g transform={mirror ? "translate(200 0) scale(-1 1)" : undefined}>
      <path className="los-s" pathLength={1} d={stem} />
      {shapes.map((l, i) => (
        <path
          key={i}
          className="los-s"
          pathLength={1}
          d={l.d}
          style={draw ? { animationDelay: Math.round(200 + (i / shapes.length) * 900) + "ms", animationDuration: "900ms" } : undefined}
        />
      ))}
    </g>
  );
  return (
    <div ref={ref} className={"relative inline-grid place-items-center " + cls + " " + className} style={{ width: size, height: size }}>
      {draw && <OrnamentStyles />}
      <svg
        viewBox="0 0 200 200"
        width={size}
        height={size}
        className="absolute inset-0 overflow-visible"
        fill="none"
        stroke={c}
        // One CSS pixel at any size. (Not vector-effect: a non-scaling stroke
        // measures the normalised dash in screen space and cuts leaves short.)
        strokeWidth={200 / Math.max(40, size)}
        strokeLinejoin="round"
        role={title ? "img" : undefined}
        aria-label={title}
        aria-hidden={title ? undefined : true}
      >
        {branch(false)}
        {branch(true)}
      </svg>
      {children !== undefined && <div className="relative text-center">{children}</div>}
    </div>
  );
}

// ---- OrnamentRule ---------------------------------------------------------------

export type OrnamentRuleProps = {
  device?: "lozenge" | "points" | "leaves";
  color?: string;
  /** Colour of the two lines, if different from the device (e.g. a quiet ink rule with a gold device). */
  lineColor?: string;
  draw?: boolean;
  className?: string;
};

export function OrnamentRule({ device = "lozenge", color, lineColor, draw = false, className = "" }: OrnamentRuleProps) {
  const c = colour(color);
  const line = lineColor && HEX.test(lineColor) ? lineColor : c;
  const { ref, cls } = useDraw<HTMLDivElement>(draw);
  const glyph =
    device === "points" ? (
      <>
        <path className="los-s" pathLength={1} d="M4 8 L8 4 L12 8 L8 12 Z" />
        <path className="los-s" pathLength={1} d="M20 8 L26 2 L32 8 L26 14 Z" />
        <path className="los-s" pathLength={1} d="M40 8 L44 4 L48 8 L44 12 Z" />
      </>
    ) : device === "leaves" ? (
      <>
        <path className="los-s" pathLength={1} d="M26 8 Q 15 1 3 8 Q 15 15 26 8 Z" />
        <path className="los-s" pathLength={1} d="M26 8 Q 37 1 49 8 Q 37 15 26 8 Z" />
      </>
    ) : (
      <>
        <path className="los-s" pathLength={1} d="M14 8 L26 1 L38 8 L26 15 Z" />
        <path className="los-s" pathLength={1} d="M20 8 L26 4.5 L32 8 L26 11.5 Z" />
      </>
    );
  return (
    <div ref={ref} role="separator" className={"flex w-full items-center gap-3 " + cls + " " + className}>
      {draw && <OrnamentStyles />}
      <span aria-hidden className="los-g h-px flex-1 origin-right" style={{ background: line }} />
      <svg aria-hidden width="52" height="16" viewBox="0 0 52 16" fill="none" stroke={c} strokeWidth={1} className="shrink-0 overflow-visible">
        {glyph}
      </svg>
      <span aria-hidden className="los-g h-px flex-1 origin-left" style={{ background: line }} />
    </div>
  );
}

// ---- Measured shapes: Cartouche and OrnamentFrame ---------------------------------

/** The element's layout size, measured by its own ResizeObserver callback. */
function useBox<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [box, setBox] = useState({ w: 0, h: 0 });
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const ro = new ResizeObserver(() => {
      const w = el.offsetWidth;
      const h = el.offsetHeight;
      setBox((b) => (b.w === w && b.h === h ? b : { w, h }));
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
  return { ref, box };
}

function concave(w: number, h: number, inset: number, r: number) {
  const f = (n: number) => n.toFixed(2);
  const x0 = inset + 0.5;
  const y0 = inset + 0.5;
  const x1 = w - inset - 0.5;
  const y1 = h - inset - 0.5;
  const k = Math.max(2, Math.min(r, (x1 - x0) / 3, (y1 - y0) / 3));
  // Each corner is cut by a quarter circle bowing inward.
  return (
    "M" + f(x0 + k) + " " + f(y0) + "L" + f(x1 - k) + " " + f(y0) +
    "A" + f(k) + " " + f(k) + " 0 0 0 " + f(x1) + " " + f(y0 + k) +
    "L" + f(x1) + " " + f(y1 - k) +
    "A" + f(k) + " " + f(k) + " 0 0 0 " + f(x1 - k) + " " + f(y1) +
    "L" + f(x0 + k) + " " + f(y1) +
    "A" + f(k) + " " + f(k) + " 0 0 0 " + f(x0) + " " + f(y1 - k) +
    "L" + f(x0) + " " + f(y0 + k) +
    "A" + f(k) + " " + f(k) + " 0 0 0 " + f(x0 + k) + " " + f(y0) + "Z"
  );
}

export type CartoucheProps = {
  color?: string;
  /** Radius of the concave corners in CSS pixels. */
  corner?: number;
  draw?: boolean;
  children?: ReactNode;
  className?: string;
};

export function Cartouche({ color, corner = 14, draw = false, children, className = "" }: CartoucheProps) {
  const c = colour(color);
  const { ref, box } = useBox<HTMLDivElement>();
  const { ref: drawRef, cls } = useDraw<HTMLDivElement>(draw);
  return (
    <div ref={drawRef} className={"inline-block " + cls + " " + className}>
      {draw && <OrnamentStyles />}
      <div ref={ref} className="relative px-10 py-6 text-center">
        {box.w > 0 && (
          <svg aria-hidden width={box.w} height={box.h} className="pointer-events-none absolute inset-0" fill="none" stroke={c} strokeWidth={1}>
            <path className="los-s" pathLength={1} d={concave(box.w, box.h, 0, corner)} />
            <path className="los-s" pathLength={1} d={concave(box.w, box.h, 5, corner - 3)} strokeOpacity={0.5} style={{ animationDelay: "200ms" }} />
          </svg>
        )}
        <div className="relative">{children}</div>
      </div>
    </div>
  );
}

export type OrnamentFrameProps = {
  color?: string;
  /** Gap between the two lines, and the size of the corner blocks, in CSS pixels. */
  gap?: number;
  draw?: boolean;
  children?: ReactNode;
  className?: string;
};

export function OrnamentFrame({ color, gap = 7, draw = false, children, className = "" }: OrnamentFrameProps) {
  const c = colour(color);
  const g = Math.max(4, Math.min(16, gap));
  const { ref, box } = useBox<HTMLDivElement>();
  const { ref: drawRef, cls } = useDraw<HTMLDivElement>(draw);
  const f = (n: number) => n.toFixed(2);
  const rect = (i: number) => {
    const x0 = i + 0.5;
    const y0 = i + 0.5;
    const x1 = box.w - i - 0.5;
    const y1 = box.h - i - 0.5;
    return "M" + f(x0) + " " + f(y0) + "H" + f(x1) + "V" + f(y1) + "H" + f(x0) + "Z";
  };
  const corners = [
    [0, 0],
    [box.w - g, 0],
    [box.w - g, box.h - g],
    [0, box.h - g],
  ];
  return (
    <div ref={drawRef} className={cls + " " + className}>
      {draw && <OrnamentStyles />}
      <div ref={ref} className="relative" style={{ padding: g * 4 + "px " + g * 5 + "px" }}>
        {box.w > 0 && (
          <svg aria-hidden width={box.w} height={box.h} className="pointer-events-none absolute inset-0" fill="none" stroke={c} strokeWidth={1}>
            <path className="los-s" pathLength={1} d={rect(0)} />
            <path className="los-s" pathLength={1} d={rect(g)} strokeOpacity={0.55} style={{ animationDelay: "200ms" }} />
            {corners.map(([x, y], i) => (
              <path
                key={i}
                className="los-s"
                pathLength={1}
                d={"M" + f(x + 0.5) + " " + f(y + 0.5) + "h" + (g - 1) + "v" + (g - 1) + "h" + (1 - g) + "Z"}
                style={{ animationDelay: "900ms", animationDuration: "600ms" }}
              />
            ))}
          </svg>
        )}
        <div className="relative">{children}</div>
      </div>
    </div>
  );
}
Notes

About this asset

Classical ornament turns into clip-art the moment its lines thicken or it repeats down a page, so these are drawn in code at one stroke weight that stays a single pixel at any size, and they are meant to be used once. The laurel is two branches of lanceolate leaves on C-curves, alternating and shrinking toward the tips, with the stems crossing at the foot; the number of leaves and how far the branches climb are props, and anything you pass sits in the middle, such as a founding year. The rule fills its width and carries a small device at its centre: a lozenge, three points or a pair of leaves. The cartouche is a doubled panel with concave corners around a lot number or an initial, and the frame is a doubled rectangle with square blocks at its corners. Each can trace itself once when it comes into view and then hold still. They take the kit's gold by default, or burgundy, or ink.

Curator’s note

Part of the Neoclassical kit, drop 3, phase 9: the kit's ornament set, which takes the place of a motion primitive for this style. Free: static SVG with an optional one-off draw.

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
New

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.

cardproduct cardauction
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