gaia
uifooter-glowsource: ok · refreshed
Footer Glow
A glowing footer backdrop with an interactive halftone wordmark.
npx shadcn add "https://ui.heygaia.io/r/footer-glow.json"free
Where it goes
footer
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Files3 · source cached 10/8/2026, 11:24:21 AM
registry/new-york/ui/footer-glow.tsx
"use client";
import Image from "next/image";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { FooterWordmark } from "@/registry/new-york/ui/footer-wordmark";
import { GrainOverlay } from "@/registry/new-york/ui/grain-overlay";
export interface FooterGlowProps {
/** The word rendered as the halftone dot grid. */
text: string;
/** Optional image mark composed at the same height, left of the text. */
logoSrc?: string;
/** Optional wallpaper image. Falls back to a CSS glow when omitted. */
backgroundSrc?: string;
/** Accent color of the fallback CSS glow. */
glowColor?: string;
/** Font classes for the wordmark lettering (any family your site uses). */
fontClassName?: string;
/** Content rendered over the glow, below the wordmark (e.g. a bottom bar). */
children?: React.ReactNode;
className?: string;
}
/**
* Glowing footer backdrop with an interactive halftone wordmark: a radial
* glow (or wallpaper image) rising from the bottom edge, film grain to break
* up banding, and a canvas-drawn dot-grid lockup that swells around the
* pointer and ripples on click. Drop it at the bottom of your own footer.
* Designed dark: the glow and blend modes assume a dark page beneath.
*/
export function FooterGlow({
text,
logoSrc,
backgroundSrc,
glowColor = "#00a3ff",
fontClassName,
children,
className,
}: FooterGlowProps) {
return (
<div
className={cn("relative w-full overflow-hidden bg-black", className)}
role="presentation"
>
{backgroundSrc ? (
<Image
src={backgroundSrc}
alt=""
fill
sizes="100vw"
className="pointer-events-none z-0 origin-bottom scale-150 select-none object-cover object-bottom"
/>
) : (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0"
// Two layers: a white-hot core hugging the bottom edge inside
// a tall ambient wash. The wash must stay strong behind the
// whole wordmark — its dots are overlay-blended, so they only
// read bright against a bright backdrop and vanish over black.
style={{
background: [
`radial-gradient(55% 55% at 50% 112%, color-mix(in srgb, ${glowColor} 55%, white) 0%, transparent 70%)`,
`radial-gradient(95% 150% at 50% 115%, ${glowColor} 0%, color-mix(in srgb, ${glowColor} 85%, transparent) 32%, color-mix(in srgb, ${glowColor} 40%, transparent) 58%, transparent 82%)`,
].join(", "),
}}
/>
)}
{/* Fade the top edge into the page above so the backdrop never
reads as a hard line against the content above. */}
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-gradient-to-b from-background via-background/50 to-transparent" />
{/* Film grain breaks up gradient banding in the glow. */}
<GrainOverlay variant="surface" className="z-20" />
{/* Content. Deliberately NO z-index on this wrapper: a stacking
context here would isolate blending and leave the wordmark's
mix-blend-mode with an empty backdrop. */}
<div className="relative flex flex-col gap-8 px-6 pt-12 pb-8 sm:px-8 lg:px-10">
{/* Slightly inset so the lockup never touches the edges. */}
<FooterWordmark
text={text}
logoSrc={logoSrc}
fontClassName={fontClassName}
className="mx-auto w-[88%] max-w-6xl"
/>
{children}
</div>
</div>
);
}
registry/new-york/ui/footer-wordmark.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/* -------------------------------------------------------------------------
* Tuning constants
* ---------------------------------------------------------------------- */
/** Gap between the mark and the lettering, as a fraction of the row height. */
const GAP_RATIO = 0.22;
const TEXT_RGB = "#e4e4e7";
const COVERAGE_FLOOR = 0.08;
const MIN_DOT_RADIUS = 0.45;
// Pointer-interaction tuning.
/** Extra swell at the pointer, fading to zero over RIPPLE_RADIUS. */
const RIPPLE_LIFT = 0.55;
const RIPPLE_RADIUS = 190;
/** Seconds for the ripple center to catch up to the cursor (exp smoothing). */
const CURSOR_FOLLOW_TAU = 0.06;
/** Seconds for a dot's radius to reach its target swell (exp smoothing). */
const SWELL_TAU = 0.09;
/** Radius delta (css px) under which a dot counts as settled. */
const SETTLE_EPSILON = 0.01;
// Click-ripple tuning.
/** Peak swell of the click wave at its origin. */
const CLICK_AMP = 0.55;
/** Wavefront speed across the wordmark, css px per second. */
const CLICK_SPEED = 900;
/** Seconds for one dot to complete its swell bump as the front passes. */
const CLICK_RISE = 0.32;
/** Fraction of the amplitude lost at the far edge of the canvas. */
const CLICK_EDGE_FALLOFF = 0.45;
/** Opacity of the un-blended white layer at the peak of the click wave. */
const CLICK_GLOW_ALPHA = 0.9;
/** Below this the white layer is invisible: skip the fill entirely. */
const GLOW_EPSILON = 0.02;
/* -------------------------------------------------------------------------
* Internal types
* ---------------------------------------------------------------------- */
interface WordRaster {
pixels: Uint8ClampedArray;
width: number;
height: number;
}
interface Dot {
x: number;
y: number;
base: number;
radius: number;
/** 0..1 share of the click wave at this dot, drives the white layer. */
glow: number;
}
/** A click-ripple wavefront expanding from a pointerdown point. */
interface ClickWave {
x: number;
y: number;
/** performance.now() at pointerdown. */
start: number;
}
/** Per-frame inputs for {@link updateDot}: everything the physics needs. */
interface DotUpdateContext {
now: number;
cssW: number;
ease: number;
cursorX: number;
cursorY: number;
pointerInside: boolean;
waves: ClickWave[];
}
/* -------------------------------------------------------------------------
* Halftone raster + physics
* ---------------------------------------------------------------------- */
function smoothstep(a: number, b: number, x: number): number {
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
}
/**
* Ease one dot toward its target radius for this frame and update its glow.
* Returns true when the dot has settled (no further animation needed).
*/
function updateDot(dot: Dot, ctxIn: DotUpdateContext): boolean {
const { now, cssW, ease, cursorX, cursorY, pointerInside, waves } = ctxIn;
const dist = Math.hypot(dot.x - cursorX, dot.y - cursorY);
// Only dots inside the cursor's falloff circle are affected.
const ripple = RIPPLE_LIFT * smoothstep(RIPPLE_RADIUS, 0, dist);
let target = pointerInside ? ripple : 0;
// Sum every active click wave: each dot swells on a sin bump as the
// wavefront reaches it, so the ripple physically travels the lockup.
let click = 0;
for (const wave of waves) {
const d = Math.hypot(dot.x - wave.x, dot.y - wave.y);
const phase = ((now - wave.start) / 1000 - d / CLICK_SPEED) / CLICK_RISE;
if (phase < 0 || phase > 1) continue;
const edgeFalloff = 1 - CLICK_EDGE_FALLOFF * smoothstep(0, cssW, d);
click += CLICK_AMP * edgeFalloff * Math.sin(Math.PI * phase);
}
target += click;
const targetRadius = dot.base * (1 + target);
dot.radius += (targetRadius - dot.radius) * ease;
// The white layer tracks the click wave only: hovering swells the dots
// but must not repaint them.
dot.glow = Math.min(1, click / CLICK_AMP);
return Math.abs(targetRadius - dot.radius) <= SETTLE_EPSILON;
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
/**
* Compose the lockup: optional logo mark and the word at the SAME height,
* side by side, sized so the whole row spans cssW, then return pixel data.
*/
function rasterizeLockup(
family: string,
word: string,
logo: HTMLImageElement | null,
cssW: number,
): WordRaster | null {
const off = document.createElement("canvas");
const ctx = off.getContext("2d", { willReadFrequently: true });
if (!ctx) return null;
// Measure at 100px to solve the font size where logo + gap + text == cssW
// with the logo height locked to the glyph height.
ctx.font = `700 100px ${family}`;
const m100 = ctx.measureText(word);
const h100 = m100.actualBoundingBoxAscent + m100.actualBoundingBoxDescent;
const w100 = m100.width;
const logoAspect = logo ? logo.naturalWidth / logo.naturalHeight : 0;
const prefix = logo ? logoAspect + GAP_RATIO : 0;
const fontPx = (100 * cssW) / (h100 * prefix + w100);
ctx.font = `700 ${fontPx}px ${family}`;
const m = ctx.measureText(word);
const ascent = m.actualBoundingBoxAscent;
const rowH = ascent + m.actualBoundingBoxDescent;
const logoW = rowH * logoAspect;
off.width = Math.ceil(cssW);
off.height = Math.ceil(rowH);
ctx.font = `700 ${fontPx}px ${family}`;
ctx.textBaseline = "alphabetic";
if (logo) ctx.drawImage(logo, 0, 0, logoW, rowH);
ctx.fillStyle = TEXT_RGB;
ctx.fillText(word, logo ? logoW + rowH * GAP_RATIO : 0, ascent);
return {
pixels: ctx.getImageData(0, 0, off.width, off.height).data,
width: off.width,
height: off.height,
};
}
interface CellSample {
coverage: number;
r: number;
g: number;
b: number;
}
/** 3x3 area sample for one grid cell: alpha coverage plus the alpha-weighted
* average color, so any logo shading carries through into the dots. */
function sampleCell(
raster: WordRaster,
col: number,
row: number,
cell: number,
): CellSample {
let aAcc = 0;
let rAcc = 0;
let gAcc = 0;
let bAcc = 0;
for (let sy = 0; sy < 3; sy++) {
for (let sx = 0; sx < 3; sx++) {
const px = Math.min(
raster.width - 1,
Math.floor(col * cell + ((sx + 0.5) * cell) / 3),
);
const py = Math.min(
raster.height - 1,
Math.floor(row * cell + ((sy + 0.5) * cell) / 3),
);
const i = (py * raster.width + px) * 4;
const a = raster.pixels[i + 3];
aAcc += a;
rAcc += raster.pixels[i] * a;
gAcc += raster.pixels[i + 1] * a;
bAcc += raster.pixels[i + 2] * a;
}
}
const coverage = aAcc / (9 * 255);
return {
coverage,
r: aAcc > 0 ? Math.round(rAcc / aAcc) : 0,
g: aAcc > 0 ? Math.round(gAcc / aAcc) : 0,
b: aAcc > 0 ? Math.round(bAcc / aAcc) : 0,
};
}
/** Area-true halftone: uniform grid, fully opaque dots. Tone is carried by
* dot size alone; luminance is contrast-stretched then posterized so flat
* brand shades read as distinctly different dot sizes. */
function buildDots(
raster: WordRaster,
cssW: number,
cssH: number,
cell: number,
): Dot[] {
// High fill ratio: with the fine grid the letters need dense ink to read
// bright through the overlay blend; the sub-cell gap still keeps the dot
// texture visible (this matches the landing page render).
const maxR = cell * 0.42;
const cols = Math.floor(cssW / cell);
const rows = Math.floor(raster.height / cell);
const dots: Dot[] = [];
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const { coverage, r, g, b } = sampleCell(raster, col, row, cell);
if (coverage <= COVERAGE_FLOOR) continue;
const x = (col + 0.5) * cell;
const y = (row + 0.5) * cell;
const t = Math.min(1, y / cssH);
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
const stretched = Math.min(1, Math.max(0, (lum - 0.1) / 0.55));
const toneMul = stretched < 0.38 ? 0.28 : stretched < 0.72 ? 0.62 : 1.0;
const base =
maxR *
Math.sqrt(coverage) *
toneMul *
(1 - 0.7 * smoothstep(0.2, 1.05, t));
if (base < MIN_DOT_RADIUS) continue;
dots.push({ x, y, base, radius: base, glow: 0 });
}
}
return dots;
}
/** Draw every dot as one batched path. */
function drawDots(ctx: CanvasRenderingContext2D, dots: Dot[]): void {
ctx.fillStyle = "rgba(255,255,255,0.9)";
ctx.beginPath();
for (const dot of dots) {
ctx.moveTo(dot.x + dot.radius, dot.y);
ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2);
}
ctx.fill();
}
/** White layer that rides the click wave, on its own un-blended canvas, so
* the wave reads as plain white as it travels. Dots are bucketed by opacity
* so the layer draws in a handful of batched paths. */
function drawGlow(ctx: CanvasRenderingContext2D, dots: Dot[]): void {
const buckets = new Map<number, Dot[]>();
for (const dot of dots) {
if (dot.glow < GLOW_EPSILON) continue;
const step = Math.round(dot.glow * 10);
const bucket = buckets.get(step);
if (bucket) bucket.push(dot);
else buckets.set(step, [dot]);
}
for (const [step, bucketDots] of buckets) {
ctx.fillStyle = `rgba(255,255,255,${(step / 10) * CLICK_GLOW_ALPHA})`;
ctx.beginPath();
for (const dot of bucketDots) {
ctx.moveTo(dot.x + dot.radius, dot.y);
ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2);
}
ctx.fill();
}
}
/**
* Pointer interaction: dots near the cursor swell and trail it, and a
* pointerdown sends a wavefront rippling across the lockup. The rAF loop
* runs while the pointer is inside, a wave is alive, or any dot is easing
* back to rest. Returns a cleanup that detaches listeners and cancels it.
*/
function attachPointerInteraction(
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
glowCtx: CanvasRenderingContext2D,
dots: Dot[],
cssW: number,
cssH: number,
): () => void {
let pointerInside = false;
let pointerX = 0;
let pointerY = 0;
// Smoothed ripple center trailing the raw pointer position.
let cursorX = 0;
let cursorY = 0;
let cursorAdopted = false;
let lastFrameAt = 0;
const waves: ClickWave[] = [];
let raf = 0;
const toCanvasPoint = (e: PointerEvent): { x: number; y: number } => {
const rect = canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
const frame = (now: number): void => {
const dt = lastFrameAt
? Math.min((now - lastFrameAt) / 1000, 0.05)
: 1 / 60;
lastFrameAt = now;
// Prune click waves whose front has crossed the whole canvas.
const waveLifetime = (cssW / CLICK_SPEED + CLICK_RISE + 0.25) * 1000;
for (let i = waves.length - 1; i >= 0; i--) {
if (now - waves[i].start > waveLifetime) waves.splice(i, 1);
}
if (!cursorAdopted) {
cursorX = pointerX;
cursorY = pointerY;
cursorAdopted = true;
}
const follow = 1 - Math.exp(-dt / CURSOR_FOLLOW_TAU);
cursorX += (pointerX - cursorX) * follow;
cursorY += (pointerY - cursorY) * follow;
const ease = 1 - Math.exp(-dt / SWELL_TAU);
let settled = true;
for (const dot of dots) {
if (
!updateDot(dot, {
now,
cssW,
ease,
cursorX,
cursorY,
pointerInside,
waves,
})
) {
settled = false;
}
}
ctx.clearRect(0, 0, cssW, cssH);
drawDots(ctx, dots);
glowCtx.clearRect(0, 0, cssW, cssH);
drawGlow(glowCtx, dots);
const active = waves.length > 0 || pointerInside || !settled;
raf = active ? requestAnimationFrame(frame) : 0;
if (!raf) lastFrameAt = 0;
};
const start = (): void => {
if (raf) return;
raf = requestAnimationFrame(frame);
};
const onEnter = (e: PointerEvent): void => {
const p = toCanvasPoint(e);
pointerInside = true;
pointerX = p.x;
pointerY = p.y;
start();
};
const onMove = (e: PointerEvent): void => {
const p = toCanvasPoint(e);
pointerX = p.x;
pointerY = p.y;
// A pointermove on the canvas implies the pointer is over it: adopt
// it unconditionally so a rebuild under a stationary pointer doesn't
// leave the swell dead until a leave/re-enter cycle.
pointerInside = true;
start();
};
const onDown = (e: PointerEvent): void => {
const p = toCanvasPoint(e);
waves.push({ x: p.x, y: p.y, start: performance.now() });
start();
};
const onLeave = (): void => {
pointerInside = false;
// Draw one final frame at rest.
start();
};
canvas.addEventListener("pointerenter", onEnter);
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerdown", onDown);
canvas.addEventListener("pointerleave", onLeave);
canvas.addEventListener("pointercancel", onLeave);
// If the pointer is already over the canvas when the interaction
// attaches, adopt it; the first pointermove corrects the ripple center.
if (canvas.matches(":hover")) {
pointerInside = true;
pointerX = cssW / 2;
pointerY = cssH / 2;
start();
}
return () => {
canvas.removeEventListener("pointerenter", onEnter);
canvas.removeEventListener("pointermove", onMove);
canvas.removeEventListener("pointerdown", onDown);
canvas.removeEventListener("pointerleave", onLeave);
canvas.removeEventListener("pointercancel", onLeave);
if (raf) cancelAnimationFrame(raf);
};
}
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = React.useState(false);
React.useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(mq.matches);
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return reduced;
}
/* -------------------------------------------------------------------------
* FooterWordmark
* ---------------------------------------------------------------------- */
export interface FooterWordmarkProps {
/** The word to render as a halftone dot grid. */
text: string;
/** Optional image mark composed at the same height, left of the text. */
logoSrc?: string;
/** Reserved aspect ratio that prevents layout shift before first draw. */
aspectRatio?: string;
/** Applied to the font probe: controls the family used for the raster. */
fontClassName?: string;
className?: string;
}
/**
* Halftone wordmark: optional logo mark and display lettering composed at
* equal height, rasterized offscreen and redrawn as a color-sampled dot grid
* that fades toward the bottom. Dots swell around the pointer and a click
* ripples across the lockup (static under reduced motion). Decorative and
* DPR-crisp; designed to blend over a dark, glowing backdrop.
*/
export function FooterWordmark({
text,
logoSrc,
aspectRatio = "23 / 4",
fontClassName = "font-serif font-bold",
className,
}: FooterWordmarkProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const glowRef = React.useRef<HTMLCanvasElement>(null);
const probeRef = React.useRef<HTMLSpanElement>(null);
const shouldReduceMotion = usePrefersReducedMotion();
React.useEffect(() => {
const canvas = canvasRef.current;
const glowCanvas = glowRef.current;
const probe = probeRef.current;
if (!canvas || !glowCanvas || !probe) return;
let cancelled = false;
let detachInteraction: (() => void) | null = null;
const build = async () => {
detachInteraction?.();
detachInteraction = null;
// Hashed/next-font families resolve from a probe element.
const family = getComputedStyle(probe).fontFamily;
const [logo] = await Promise.all([
logoSrc ? loadImage(logoSrc).catch(() => null) : null,
document.fonts.load(`700 100px ${family}`).catch(() => undefined),
]);
if (cancelled) return;
const cssW = canvas.clientWidth;
if (!cssW) return;
const raster = rasterizeLockup(family, text, logo, cssW);
if (!raster) return;
const cssH = raster.height;
// Scale the grid with width so narrow renders keep the same fine
// halftone texture as the full-width landing page instead of a
// coarse blocky grid (aim for 20+ dot rows per letter).
const cell = Math.max(4, Math.min(9, cssW / 150));
const dpr = Math.min(window.devicePixelRatio || 1, 2.5);
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
canvas.style.height = `${cssH}px`;
glowCanvas.width = canvas.width;
glowCanvas.height = canvas.height;
const ctx = canvas.getContext("2d");
const glowCtx = glowCanvas.getContext("2d");
if (!ctx || !glowCtx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
glowCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
const dots = buildDots(raster, cssW, cssH, cell);
drawDots(ctx, dots);
glowCtx.clearRect(0, 0, cssW, cssH);
if (shouldReduceMotion) return;
detachInteraction = attachPointerInteraction(
canvas,
ctx,
glowCtx,
dots,
cssW,
cssH,
);
};
build();
let timer: ReturnType<typeof setTimeout>;
const onResize = () => {
clearTimeout(timer);
timer = setTimeout(build, 180);
};
window.addEventListener("resize", onResize);
return () => {
cancelled = true;
detachInteraction?.();
clearTimeout(timer);
window.removeEventListener("resize", onResize);
};
}, [text, logoSrc, shouldReduceMotion]);
return (
<div className={className}>
{/* Invisible probe that resolves the font family for the canvas. */}
<span
ref={probeRef}
aria-hidden="true"
className={cn("absolute h-0 w-0 overflow-hidden", fontClassName)}
/>
{/* Two stacked layers, because a canvas can only carry one blend
mode: the base halftone blends with the backdrop, and the click
ripple fades in an un-blended white copy of the same dots on
top. `relative` with NO z-index keeps both blending against the
footer background: a stacking context would isolate them. */}
<div className="relative" role="presentation">
<canvas
ref={canvasRef}
className="block w-full mix-blend-overlay"
style={{ aspectRatio }}
/>
<canvas
ref={glowRef}
className="pointer-events-none absolute inset-0 block h-full w-full"
/>
</div>
</div>
);
}
registry/new-york/ui/grain-overlay.tsx
import { cn } from "@/lib/utils";
/**
* Fractal-noise tile rendered by the browser's own SVG filter, inlined as a
* data URI so it costs no request and no decode. `stitchTiles` keeps the tile
* seamless when it repeats, so the grain never shows a grid on large
* surfaces. A single octave at a high base frequency reads as film grain;
* `feColorMatrix` desaturates it so the speckle doesn't tint the backdrop.
*/
const GRAIN_TILE =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='1' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E\")";
/**
* How the grain is composited:
*
* - `photo`: detailed, mostly dark artwork. Overlay blending degrades to
* multiply against dark pixels, so composite normally at low opacity.
* - `surface`: smooth gradients and flat color. Overlay ties the speckle to
* the underlying hue, and a much lower opacity is enough.
*/
const VARIANT_CLASSES = {
photo: "opacity-[0.24]",
surface: "opacity-[0.06] mix-blend-overlay",
} as const;
export interface GrainOverlayProps {
/** Compositing mode: `photo` for imagery, `surface` for gradients. */
variant?: keyof typeof VARIANT_CLASSES;
/** Match the parent's radius so the grain is clipped to the same shape. */
className?: string;
}
/**
* Film-grain layer for images and large gradient surfaces: breaks up
* banding and gives flat artwork a tactile, printed feel. Absolutely
* positioned, so the parent must be positioned and should clip
* (`overflow-hidden` or a matching radius on this element).
*/
export function GrainOverlay({
variant = "photo",
className,
}: GrainOverlayProps) {
return (
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0",
VARIANT_CLASSES[variant],
className,
)}
style={{ backgroundImage: GRAIN_TILE }}
/>
);
}