a security hologram: a print shown in a band of foil at the hand
npx shadcn add @ag/holoAlready installed? Preview an update
npx shadcn add @ag/holo --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-13
- New: a security hologram for any rounded surface.
HoloLightmeasures the hand over what it holds, andHoloLeaninside it leans a few degrees toward it (pose="flat", or"turned"beside a headline);Hololays a print (laminate,webornone) 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.floorkeeps 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 incqw) sets it in that unit, so the print grows and shrinks with everything printed on it.FoilEdgeis a one-pixel ring of foil on the rim, faint at rest and lit in the band under the hand.emblemis the glyph the prints repeat, your mark. The prints arelib/holo-prints, framework-free SVG, for a drawing that is not CSS too. Under reduced motion nothing follows the hand.
the demo above, verbatim
"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
"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
}
/>
)
}// 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)}")`
}