Wax Seal
A hand-poured wax seal with your initials.
Install
$ npx shadcn@latest add https://ephemera.kerrshift.com/r/wax-seal.jsonOr copy the source below into components/ui/wax-seal.tsx. It needs React and Tailwind, nothing else.
Usage
example.tsx
import { WaxSeal } from "@/components/ui/wax-seal";
<WaxSeal color="#8e1b1f">AB</WaxSeal>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | "E" | Initials or a short mark. |
| color | string | "#8e1b1f" | Wax color. |
| size | number | 72 | Size in px. |
| seed | number | 3 | Changes the shape of the wax drip. |
Any other props are passed to the root element.
Fonts
Components read optional CSS variables and fall back to system fonts: --font-display, --font-receipt, --font-type, --font-hand and --font-stamp. This site uses Fraunces, IBM Plex Mono, Courier Prime, Caveat and Anton.
Source
components/ui/wax-seal.tsx
import * as React from "react";
export interface WaxSealProps extends React.SVGAttributes<SVGSVGElement> {
/** Initials or a short mark, 1–3 characters looks best. */
children?: React.ReactNode;
color?: string;
size?: number;
/** Changes the shape of the wax drip. */
seed?: number;
}
function blob(seed: number, cx: number, cy: number, r: number) {
const n = 14;
const pts: [number, number][] = [];
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2;
const k = 1 + 0.07 * Math.sin(i * 2.3 + seed) + 0.05 * Math.cos(i * 5.1 + seed * 1.7);
pts.push([cx + Math.cos(a) * r * k, cy + Math.sin(a) * r * k]);
}
const mid = (p: [number, number], q: [number, number]) => [(p[0] + q[0]) / 2, (p[1] + q[1]) / 2];
const m0 = mid(pts[n - 1], pts[0]);
let d = `M ${m0[0].toFixed(2)} ${m0[1].toFixed(2)}`;
for (let i = 0; i < n; i++) {
const p = pts[i];
const m = mid(p, pts[(i + 1) % n]);
d += ` Q ${p[0].toFixed(2)} ${p[1].toFixed(2)} ${m[0].toFixed(2)} ${m[1].toFixed(2)}`;
}
return d + " Z";
}
export function WaxSeal({ children = "E", color = "#8e1b1f", size = 72, seed = 3, ...props }: WaxSealProps) {
const id = React.useId().replace(/:/g, "");
return (
<svg width={size} height={size} viewBox="0 0 100 100" role="img" aria-label="Wax seal" {...props}>
<defs>
<radialGradient id={`${id}-g`} cx="38%" cy="32%" r="75%">
<stop offset="0" stopColor="#fff" stopOpacity=".35" />
<stop offset=".35" stopColor="#fff" stopOpacity="0" />
<stop offset="1" stopColor="#000" stopOpacity=".35" />
</radialGradient>
<filter id={`${id}-s`} x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="2" floodOpacity=".35" />
</filter>
</defs>
<g filter={`url(#${id}-s)`}>
<path d={blob(seed, 50, 50, 44)} fill={color} />
<path d={blob(seed, 50, 50, 44)} fill={`url(#${id}-g)`} />
</g>
<circle cx="50" cy="50" r="30" fill={color} />
<circle cx="50" cy="50" r="30" fill="none" stroke="#000" strokeOpacity=".3" strokeWidth="2.5" />
<circle cx="50" cy="51" r="30" fill="none" stroke="#fff" strokeOpacity=".22" strokeWidth="1.2" />
<circle cx="50" cy="50" r="24" fill="none" stroke="#000" strokeOpacity=".22" strokeWidth="1" strokeDasharray="1.5 2.5" />
<text
x="50"
y="51"
textAnchor="middle"
dominantBaseline="central"
fontSize="24"
fontStyle="italic"
fontWeight="700"
fill="#000"
fillOpacity=".38"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{children}
</text>
<text
x="50"
y="50"
textAnchor="middle"
dominantBaseline="central"
fontSize="24"
fontStyle="italic"
fontWeight="700"
fill="#fff"
fillOpacity=".18"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{children}
</text>
</svg>
);
}