← ui

holo

ui

a security hologram: a print shown in a band of foil at the hand

npx shadcn add @ag/holo
Already installed? Preview an update
npx shadcn add @ag/holo --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-13
  • New: a security hologram for any rounded surface. HoloLight measures the hand over what it holds, and HoloLean inside it leans a few degrees toward it (pose="flat", or "turned" beside a headline); Holo lays a print (laminate, web or none) and a seal you place, shown in a band of foil where the light falls, which answers the hand's place and the tilt it gives and eases as the lean does: a whisper at rest and under a fifth under the hand. floor keeps some of the print outside the band (also inherited as --holo-floor). The laminate's tiles are in pixels unless the surface sets --holo-print-unit: a surface sized by its own width (a card in cqw) sets it in that unit, so the print grows and shrinks with everything printed on it. FoilEdge is a one-pixel ring of foil on the rim, faint at rest and lit in the band under the hand. emblem is the glyph the prints repeat, your mark. The prints are lib/holo-prints, framework-free SVG, for a drawing that is not CSS too. Under reduced motion nothing follows the hand.
Updated 2026-10-13 · release notes

also called hologram, laminate, foil edge

shelf glass & surfaces

uses foil

the demo above, verbatim

holo.demo.tsx
"use client"

import { Sample } from "@/app/samples"
import {
  FoilEdge,
  Holo,
  HoloLean,
  HoloLight,
  type HoloPrint,
} from "@/registry/base-nova/ui/holo"
import { Glass } from "@/registry/base-nova/ui/material-glass"

// #region card
/** An ID card in glass: the laminate's print and a seal biting the photo's corner,
 *  the foil on its rim. Nearly nothing face on; bring the hand over it and a band of
 *  foil follows it across, the card leaning a few degrees toward it. Everything on
 *  it is a share of its width (cqw), so it is the same card at any size. */
function Card() {
  return (
    <HoloLight className="@container w-full max-w-md">
      <HoloLean pose="flat">
        <Glass
          shape="surface"
          className="relative flex aspect-[85.6/53.98] w-full flex-col justify-between overflow-hidden p-[6cqw] text-white [--glass-radius:4.5cqw] [--glass-thickness:1]"
        >
          {/* Centred on the photo's top right corner: the photo is 28cqw, 6cqw in
            from the card's left and foot, so the corner is 34cqw from each; the
            seal (14cqw) starts 7cqw short of it. */}
          <Holo
            print="laminate"
            seal="bottom-[27cqw] left-[27cqw] size-[14cqw]"
          />
          <div className="relative flex items-center justify-between text-[3cqw] text-white/60">
            <span className="font-medium text-white/85">identity</span>
            <span className="font-mono">ES</span>
          </div>
          <div className="relative flex items-end gap-[5cqw]">
            <div className="size-[28cqw] shrink-0 rounded-[1.5cqw] bg-[url(/adriangalilea.jpg)] bg-cover shadow-[0_0_0_1px_rgb(255_255_255/0.08)]" />
            <div className="grid gap-[1cqw] pb-[1cqw]">
              <p className="text-[6cqw] leading-tight font-medium tracking-tight">
                Adrian Galilea
              </p>
              <p className="font-mono text-[3cqw] text-white/50">
                ui.adriangalilea.com
              </p>
            </div>
          </div>
          <FoilEdge />
        </Glass>
      </HoloLean>
    </HoloLight>
  )
}
// #endregion

// #region prints
/** The prints, lit as under the hand (`lit`, the light held at the middle), side by
 *  side: what the band shows of each. */
const PRINTS: { print: HoloPrint; seal?: string; label: string }[] = [
  { print: "laminate", label: "laminate" },
  { print: "web", label: "web" },
  { print: "none", seal: "inset-[18%] m-auto size-24", label: "seal" },
]

function Prints() {
  return (
    <div className="grid gap-4 sm:grid-cols-3">
      {PRINTS.map(({ print, seal, label }) => (
        <figure key={label} className="grid gap-2">
          <HoloLight lit rest={{ x: 0.5, y: 0.5 }}>
            <div className="relative aspect-[85.6/53.98] overflow-hidden rounded-xl bg-neutral-900 shadow-[0_0_0_1px_rgb(255_255_255/0.08)]">
              <Holo
                print={print}
                seal={seal}
                strength={{ rest: 0.6, lit: 0.6 }}
              />
              <FoilEdge />
            </div>
          </HoloLight>
          <figcaption className="font-mono text-xs text-muted-foreground">
            {label}
          </figcaption>
        </figure>
      ))}
    </div>
  )
}
// #endregion

export default function HoloDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="card"
        glance="large"
        title="a card someone can turn"
        description="the print shows only in the band of light at the hand; the card leans toward it"
        with="card"
      >
        <div className="dark grid place-items-center rounded-xl bg-neutral-950 p-8 sm:p-12">
          <Card />
        </div>
      </Sample>
      <Sample
        name="prints"
        title="the prints"
        description="each lit and held strong, to see it: on a card it is a quarter of this under the hand"
        with="prints"
      >
        <div className="dark rounded-xl bg-neutral-950 p-6">
          <Prints />
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

holo.tsx
"use client"

// A HOLOGRAM, as a security laminate is one: nearly invisible face on, and as the
// surface turns a BAND of iridescent light runs across it, showing its print only
// where the band is. The band answers the hand, never a clock: `HoloLight`
// measures the pointer over what it holds (`--px`, `--py` from 0 to 1, `--hx` in px,
// `data-hover` while it is there) and everything inside reads that; the band falls
// by the hand's place and the tilt it gives, eased as the lean is (`AT`).
//
//   <HoloLight>                        the hand's light
//     <HoloLean pose="flat">           a lean toward it
//       <div class="relative rounded-2xl …">
//         <Holo print="laminate" seal="top-4 right-4 size-16" />
//         …the card's content
//         <FoilEdge />                 a one-pixel ring of foil on its rim
//       </div>
//     </HoloLean>
//   </HoloLight>
//
// The prints are lib/holo-prints' (masks), coloured by lib/foil's spectrum, added to
// the light (`plus-lighter`) so they light the surface and never paint over it. At
// rest a whisper (6%); under the hand under a fifth (a quarter was loud on a card
// being looked at closely), so what is written over it always
// reads: it should be found, not shown. Only the coming and going is timed. Under
// reduced motion nothing follows the hand and nothing leans.

import type * as React from "react"
import { useEffect, useRef } from "react"
import { cn } from "@/lib/utils"
import { foilGradient } from "@/registry/base-nova/lib/foil"
import {
  type Emblem,
  emblems,
  guilloche,
  printUrl,
  SPARK,
  seal as sealArt,
  web,
} from "@/registry/base-nova/lib/holo-prints"

/** Where the light rests with no hand on it: a little above the middle, where a
 *  page's light comes from. */
const REST = { x: 0.5, y: 0.35 }

/** WHERE THE LIGHT FALLS across a surface, 0 to 1 along the band's diagonal: a
 *  hologram shows what it does by the angle it is seen at, so the band answers
 *  the surface's tilt as much as the hand's place. Part where the hand is across
 *  (`--px`), part how far up or down (`--py`, which tilts it toward or away and
 *  the hand's place across alone never moved), both about the light's rest, so
 *  at rest it is the middle. Eased like the lean it goes with (`MOVES`), never a
 *  snap to the pointer. A registered number, so the easing can run. */
const AT =
  "calc(0.5 + (var(--px, 0.5) - 0.5) * 0.65 + (var(--py, 0.35) - 0.35) * 0.4)"
const MOVES = "--holo-at 600ms cubic-bezier(0.22, 1, 0.36, 1)"
if (typeof CSS !== "undefined" && CSS.registerProperty)
  try {
    CSS.registerProperty({
      name: "--holo-at",
      syntax: "<number>",
      inherits: true,
      initialValue: "0.5",
    })
  } catch {
    // Registered already: this module evaluated again (a reload in development).
  }

/** The band of light: a diagonal stripe where the light falls (`AT`), full within
 *  8% of it and down to the print's floor 40% away (shares of the gradient's
 *  line), as a mask. The floor (`--holo-floor`, 0 to 1, none by default) is how
 *  much of the print stays outside the band: a real card's print is on its whole
 *  face, the light only makes it shine. */
const OFF = "rgb(0 0 0 / var(--holo-floor, 0))"
const BAND = `linear-gradient(115deg, ${OFF} calc(var(--holo-at) * 100% - 40%), black calc(var(--holo-at) * 100% - 8%), black calc(var(--holo-at) * 100% + 8%), ${OFF} calc(var(--holo-at) * 100% + 40%))`

/** The foil under a print, four times its surface's width, slid across with the
 *  light so its hue drifts as the angle changes. */
const FOIL_UNDER: React.CSSProperties = {
  backgroundImage: foilGradient(),
  backgroundSize: "400% 100%",
  backgroundPosition: "calc(var(--holo-at) * 100%) 0",
}

/** How a lean follows the hand: about its rest, ±3° across and ±2° up and down when
 *  `flat` (facing the reader), ±4° and ±3° about a pose turned 14° away when
 *  `turned` (beside a headline). A lean, not a swing: a surface holds links, and
 *  what the hand aims at must stay where it is. In its own perspective, so it needs
 *  nothing of its parent. */
const POSES = {
  flat: "perspective(2000px) rotateY(calc((var(--px, 0.5) - 0.5) * 6deg)) rotateX(calc((var(--py, 0.35) - 0.35) * -4deg))",
  turned:
    "perspective(2000px) rotateY(calc(-14deg + (var(--px, 0.5) - 0.5) * 8deg)) rotateX(calc(6deg - (var(--py, 0.35) - 0.35) * 6deg))",
} as const

export type HoloPose = keyof typeof POSES

/** What leans toward the hand, anywhere inside a `HoloLight` (the front card of a
 *  stack, while the ones behind do something else). Quick under the hand, slow home;
 *  still under reduced motion. Its transform is `--holo-lean`, so a breakpoint can
 *  stand it straight (`max-lg:[transform:none]`). */
export function HoloLean({
  pose = "flat",
  children,
  className,
}: {
  pose?: HoloPose
  children: React.ReactNode
  className?: string
}) {
  return (
    <div
      data-slot="holo-lean"
      className={cn(
        "[transform:var(--holo-lean)] transition-transform duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[hover]/holo:duration-240 motion-reduce:[transform:none]",
        className,
      )}
      style={{ "--holo-lean": POSES[pose] } as React.CSSProperties}
    >
      {children}
    </div>
  )
}

/** The hand's light over what it holds: the pointer's place as `--px`, `--py` (0 to
 *  1) and `--hx` (px across), and `data-hover` while it is there, once a frame. On
 *  leaving the light goes back to rest. Everything inside reads it: a `Holo`, a
 *  `FoilEdge`, a `HoloLean`, and your own styles (`group-data-[hover]/holo:`). */
export function HoloLight({
  children,
  rest = REST,
  lit,
  as: Element = "div",
  className,
}: {
  children: React.ReactNode
  /** Where the light rests with no hand on it. */
  rest?: { x: number; y: number }
  /** Lit as if a hand were on it when none is (a still, a comparison). */
  lit?: boolean
  /** `span` inside a line of text. */
  as?: "div" | "span"
  className?: string
}) {
  const ref = useRef<HTMLElement>(null)
  useEffect(() => {
    const el = ref.current
    if (!el) throw new Error("HoloLight: no element to measure")
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
    let frame = 0
    let at = rest
    const write = () => {
      frame = 0
      el.style.setProperty("--px", at.x.toFixed(3))
      el.style.setProperty("--py", at.y.toFixed(3))
      el.style.setProperty("--hx", `${(at.x * el.offsetWidth).toFixed(1)}px`)
    }
    const soon = () => {
      if (!frame) frame = requestAnimationFrame(write)
    }
    const move = (e: PointerEvent) => {
      const box = el.getBoundingClientRect()
      const clamp = (n: number) => Math.min(1, Math.max(0, n))
      at = {
        x: clamp((e.clientX - box.left) / box.width),
        y: clamp((e.clientY - box.top) / box.height),
      }
      el.setAttribute("data-hover", "")
      soon()
    }
    const leave = () => {
      at = rest
      if (!lit) el.removeAttribute("data-hover")
      soon()
    }
    el.addEventListener("pointermove", move)
    el.addEventListener("pointerleave", leave)
    return () => {
      cancelAnimationFrame(frame)
      el.removeEventListener("pointermove", move)
      el.removeEventListener("pointerleave", leave)
    }
  }, [rest, lit])
  return (
    <Element
      ref={ref as React.Ref<HTMLDivElement & HTMLSpanElement>}
      data-slot="holo-light"
      data-hover={lit ? "" : undefined}
      className={cn("group/holo", className)}
      style={
        {
          "--px": rest.x,
          "--py": rest.y,
        } as React.CSSProperties
      }
    >
      {children}
    </Element>
  )
}

export type HoloPrint = "laminate" | "web" | "none"

/** One unit of the laminate's tiles: a pixel unless the surface says otherwise
 *  (`--holo-print-unit`). A surface sized by its own width (a card in `cqw`) sets
 *  it in that unit too, so the print grows and shrinks with everything printed
 *  on it, as one physical thing; in pixels it stayed one size while the rest
 *  grew. */
const UNIT = "var(--holo-print-unit, 1px)"

/** The print laid over a surface: the laminate (guilloché ribbons and the emblem
 *  repeated), the web (people linked, laid whole), or none (the seal alone). */
function printStyle(
  print: HoloPrint,
  emblem: Emblem,
): React.CSSProperties | null {
  if (print === "laminate")
    return {
      maskImage: `${printUrl(guilloche())}, ${printUrl(emblems(emblem))}`,
      maskSize: `calc(160 * ${UNIT}) calc(40 * ${UNIT}), calc(72 * ${UNIT}) calc(72 * ${UNIT})`,
    }
  if (print === "web")
    return {
      maskImage: printUrl(web(emblem)),
      maskSize: "100% 100%",
      maskRepeat: "no-repeat",
    }
  return null
}

/** THE HOLOGRAM over the positioned, rounded surface it is placed in (absolute,
 *  inset 0, its corners): its print and, if placed, its seal, in the foil, shown in
 *  the band of light at the hand (inside a `HoloLight`). Draw it first in the
 *  surface, so what is written there paints over it. */
export function Holo({
  print = "laminate",
  seal,
  emblem = SPARK,
  strength = { rest: 0.06, lit: 0.18 },
  floor,
  className,
}: {
  print?: HoloPrint
  /** Where the seal sits and how big, as classes (`bottom-6 left-1/3 size-14`), or
   *  none. A seal biting a photo's corner is where an ID puts one, so the photo
   *  cannot be swapped. */
  seal?: string
  /** The glyph the prints repeat and the seal holds: your mark. */
  emblem?: Emblem
  /** Its opacity with no hand on it and under the hand. */
  strength?: { rest: number; lit: number }
  /** How much of the print stays outside the band of light, 0 to 1 (none by
   *  default). Also inherited as `--holo-floor`, so a surface can set it for
   *  the holograms on it. */
  floor?: number
  className?: string
}) {
  const art = printStyle(print, emblem)
  return (
    <span
      aria-hidden
      data-slot="holo"
      className={cn(
        "pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit] mix-blend-plus-lighter",
        "opacity-(--holo-rest) group-data-[hover]/holo:opacity-(--holo-lit)",
        className,
      )}
      style={
        {
          "--holo-rest": strength.rest,
          "--holo-lit": strength.lit,
          ...(floor !== undefined && { "--holo-floor": floor }),
          "--holo-at": AT,
          transition: `opacity 500ms cubic-bezier(0.22, 1, 0.36, 1), ${MOVES}`,
          maskImage: BAND,
        } as React.CSSProperties
      }
    >
      {art && (
        <span
          data-slot="holo-print"
          data-print={print}
          className="absolute inset-0"
          style={{ ...FOIL_UNDER, ...art }}
        />
      )}
      {seal && (
        <span
          data-slot="holo-seal"
          className={cn("absolute", seal)}
          style={{
            ...FOIL_UNDER,
            maskImage: printUrl(sealArt(emblem)),
            maskSize: "contain",
            maskRepeat: "no-repeat",
          }}
        />
      )}
    </span>
  )
}

/** A FOIL EDGE: a one-pixel ring of the foil over the rim of the positioned, rounded
 *  box it is placed in, the foil's own security feature. Barely there at rest (6%),
 *  its spectrum laid across the screen (`fixed`), so an edge's colour is where it
 *  stands and shifts as the page scrolls; inside a `HoloLight` under the hand it is
 *  lit in a band where the light falls instead, at half (fuller, it outshone the
 *  card it edges). Composed, not built in: the surface's
 *  own material is untouched. */
export function FoilEdge({ className }: { className?: string }) {
  return (
    <span
      aria-hidden
      data-slot="foil-edge"
      className={cn(
        "pointer-events-none absolute inset-0 z-1 rounded-[inherit] p-px mix-blend-plus-lighter",
        "opacity-[0.06] [--edge-floor:1] group-data-[hover]/holo:opacity-50 group-data-[hover]/holo:[--edge-floor:0]",
        className,
      )}
      style={
        {
          background: `${foilGradient()} fixed`,
          // Where the light falls, as the hologram's own (`AT`), eased with it.
          "--holo-at": AT,
          transition: `opacity 300ms ease-out, ${MOVES}`,
          // The band (its floor 1 at rest: the whole ring), intersected with the
          // ring (the border box less the content box, one pixel in).
          maskImage:
            "linear-gradient(100deg, rgb(0 0 0 / var(--edge-floor)) calc(var(--holo-at) * 100% - 40%), black calc(var(--holo-at) * 100%), rgb(0 0 0 / var(--edge-floor)) calc(var(--holo-at) * 100% + 40%)), linear-gradient(#000 0 0), linear-gradient(#000 0 0)",
          maskClip: "border-box, content-box, border-box",
          maskComposite: "intersect, exclude, add",
        } as React.CSSProperties
      }
    />
  )
}
holo-prints.ts
// THE HOLOGRAM'S PRINTS, framework-free: what a security hologram is made of, drawn
// from geometry as SVG, white on nothing, for a surface to use as MASKS over the foil
// (lib/foil). A print is never a wash of colour: a smooth gradient this faint over a
// dark ground bands into mud in eight bits, where fine lines lit by a band of foil
// stay crisp. Each is from a fixed seed or pure geometry, so a server and a browser
// draw the same, and a drawing that is not CSS (an SVG, a share image) can lay the
// same art in its own patterns (`HoloArt`'s body).
//
// Four prints:
//   guilloche  the line work of banknotes, a tile that repeats seamlessly
//   emblems    a small emblem, twice in a tile, staggered: a texture
//   web        nodes joined to their nearest, a few hubs ringed: people linked
//   seal       two rings and a rosette of ticks, the emblem at its heart
// The emblem is yours (your mark), a four-pointed spark if you give none.

/** A print as SVG: its tile's size and what is drawn in it, white on nothing. */
export type HoloArt = { width: number; height: number; body: string }

/** A glyph to print: an SVG path and the box it is drawn in. */
export type Emblem = {
  path: string
  box: { x: number; y: number; w: number; h: number }
}

/** A four-pointed spark, on a 24 grid. */
export const SPARK: Emblem = {
  path: "M12 2C12.6 8.4 15.6 11.4 22 12C15.6 12.6 12.6 15.6 12 22C11.4 15.6 8.4 12.6 2 12C8.4 11.4 11.4 8.4 12 2Z",
  box: { x: 2, y: 2, w: 20, h: 20 },
}

const f = (n: number) => n.toFixed(2)

/** The emblem `size` tall, its box's top left at (x, y). */
function place(e: Emblem, x: number, y: number, size: number): string {
  const k = size / e.box.h
  return `<path transform="translate(${f(x - e.box.x * k)} ${f(y - e.box.y * k)}) scale(${f(k)})" d="${e.path}" fill="#fff"/>`
}

/** A seeded random, the same sequence for the same seed everywhere (mulberry32). */
function seeded(seed: number): () => number {
  let s = seed >>> 0
  return () => {
    s = (s + 0x6d2b79f5) >>> 0
    let t = s
    t = Math.imul(t ^ (t >>> 15), t | 1)
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
}

/** GUILLOCHÉ: five sine waves a fifth of a turn apart, one period every 80 px, in a
 *  band 40 px tall, a tile that repeats seamlessly across and stacks into ribbons. */
export function guilloche(): HoloArt {
  const W = 160
  const H = 40
  const waves = Array.from({ length: 5 }, (_, i) => {
    const phase = (i * 2 * Math.PI) / 5
    const points = Array.from({ length: W / 2 + 1 }, (_, k) => {
      const x = k * 2
      const y = H / 2 + 9 * Math.sin((2 * Math.PI * x) / 80 + phase)
      return `${x},${f(y)}`
    })
    return `<polyline points="${points.join(" ")}" fill="none" stroke="#fff" stroke-width="0.45"/>`
  })
  return { width: W, height: H, body: waves.join("") }
}

/** EMBLEMS: the emblem, 8 px, twice in a 72 px tile, staggered: small enough to read
 *  as texture before it reads as a glyph. */
export function emblems(emblem: Emblem = SPARK): HoloArt {
  return {
    width: 72,
    height: 72,
    body: place(emblem, 8, 8, 8) + place(emblem, 44, 44, 8),
  }
}

/** A SEAL: two rings, a rosette of 48 ticks between them, the emblem at its heart. */
export function seal(emblem: Emblem = SPARK): HoloArt {
  const ticks = Array.from({ length: 48 }, (_, i) => {
    const a = (i * 2 * Math.PI) / 48
    const p = (r: number) =>
      `${f(50 + r * Math.cos(a))} ${f(50 + r * Math.sin(a))}`
    return `M${p(31)}L${p(40)}`
  })
  // The emblem within the inner ring (28 across) with air round it.
  const size = 28
  const w = (size * emblem.box.w) / emblem.box.h
  return {
    width: 100,
    height: 100,
    body:
      `<circle cx="50" cy="50" r="46" fill="none" stroke="#fff" stroke-width="1.2"/>` +
      `<circle cx="50" cy="50" r="28" fill="none" stroke="#fff" stroke-width="0.8"/>` +
      `<path d="${ticks.join("")}" stroke="#fff" stroke-width="0.6"/>` +
      place(emblem, 50 - w / 2, 50 - size / 2 - 1, size),
  }
}

/** THE WEB, people linked: nodes on a jittered grid (so they spread rather than
 *  clump), each joined to its two nearest, a few hubs joined to four and drawn as the
 *  emblem in a ring. Drawn once, `aspect` wide for 1 tall (an ID card's 85.6 by
 *  53.98 by default), and laid over a surface whole, not tiled. */
export function web(
  emblem: Emblem = SPARK,
  { seed = 19, aspect = 85.6 / 53.98 } = {},
): HoloArt {
  const W = 100
  const H = W / aspect
  const random = seeded(seed)
  const COLS = 9
  const ROWS = Math.max(2, Math.round(COLS / aspect))
  const nodes = Array.from({ length: COLS * ROWS }, (_, i) => ({
    x: ((i % COLS) + 0.15 + random() * 0.7) * (W / COLS),
    y: (Math.floor(i / COLS) + 0.15 + random() * 0.7) * (H / ROWS),
    hub: random() < 0.12,
  }))
  const edges = new Set<string>()
  nodes.forEach((a, i) => {
    const nearest = nodes
      .map((b, j) => ({ j, d: Math.hypot(a.x - b.x, a.y - b.y) }))
      .filter(({ j }) => j !== i)
      .sort((p, q) => p.d - q.d)
      .slice(0, a.hub ? 4 : 2)
    for (const { j } of nearest) edges.add(i < j ? `${i}-${j}` : `${j}-${i}`)
  })
  const lines = [...edges].map((key) => {
    const [i, j] = key.split("-").map(Number)
    const a = nodes[i as number]
    const b = nodes[j as number]
    if (!a || !b) throw new Error(`web: edge ${key} names a missing node`)
    return `M${f(a.x)} ${f(a.y)}L${f(b.x)} ${f(b.y)}`
  })
  const dots = nodes.map((n) =>
    n.hub
      ? `<circle cx="${f(n.x)}" cy="${f(n.y)}" r="2.3" fill="none" stroke="#fff" stroke-width="0.14"/>` +
        place(emblem, n.x - 1.5, n.y - 1.6, 3.2)
      : `<circle cx="${f(n.x)}" cy="${f(n.y)}" r="0.35" fill="#fff"/>`,
  )
  return {
    width: W,
    height: H,
    body: `<path d="${lines.join("")}" stroke="#fff" stroke-width="0.12" fill="none"/>${dots.join("")}`,
  }
}

/** A print as a CSS image, `url("data:…")`, for `mask-image`. */
export function printUrl({ width, height, body }: HoloArt): string {
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${body}</svg>`
  return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
}