elements
blockai-badgesource: ok · hit
AI Badge
Interactive 3D badge component with Atropos effect, customizable branding, QR code, and AI avatar support
npx shadcn add "https://www.tryelements.dev/r/ai-badge.json"free
Where it goes
logo-cloud
Visual style
3dterminal-mono
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightatroposqrcode
Files2 · source cached 10/12/2026, 7:26:59 PM
registry/default/blocks/badges/ai-badge/components/elements/ai-badge.tsx
"use client";
import Atropos from "atropos/react";
import "atropos/css";
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
import {
type BadgeBackground,
type BadgeBranding,
type BadgeColors,
type BadgePatternType,
BadgePreview,
} from "./badge-preview";
export type { BadgeColors, BadgeBranding, BadgeBackground, BadgePatternType };
export interface AIBadgeProps {
/** URL of the profile picture (can be AI-generated) */
profilePictureUrl?: string | null;
/** Badge/member number (e.g., "#001") */
badgeNumber: string;
/** First name */
firstName: string;
/** Last name */
lastName?: string;
/** Role or title displayed on badge */
jobTitle: string;
/** Custom URL for QR code (defaults to current origin + /p/{number}) */
qrCodeUrl?: string;
/** Custom color palette */
colors?: Partial<BadgeColors>;
/** Custom branding (title, subtitle, logo) */
branding?: BadgeBranding;
/** Custom background (pattern, image, gradient) */
background?: BadgeBackground;
/** Additional CSS classes */
className?: string;
}
export const AIBadge = forwardRef<SVGSVGElement, AIBadgeProps>(function AIBadge(
{
profilePictureUrl,
badgeNumber,
firstName,
lastName,
jobTitle,
qrCodeUrl,
colors,
branding,
background,
className,
},
ref,
) {
return (
<div data-slot="ai-badge" className={cn("w-full", className)}>
<Atropos
className="atropos-badge w-full"
activeOffset={40}
shadowScale={1.05}
rotateXMax={15}
rotateYMax={15}
shadow={true}
highlight={true}
>
{/* Outer glass frame - furthest back */}
<div
data-atropos-offset="-3"
className="absolute inset-0 rounded-2xl"
style={{
background:
"linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)",
boxShadow:
"0 25px 80px rgba(0, 0, 0, 0.4), inset 1px 1px 2px rgba(255,255,255,0.1)",
border: "1px solid rgba(255,255,255,0.15)",
}}
/>
{/* Badge content - middle layer */}
<div
data-atropos-offset="0"
className="relative rounded-xl overflow-hidden"
style={{
boxShadow: "0 10px 40px rgba(0, 0, 0, 0.3)",
}}
>
<BadgePreview
ref={ref}
profilePictureUrl={profilePictureUrl}
badgeNumber={badgeNumber}
firstName={firstName}
lastName={lastName}
jobTitle={jobTitle}
qrCodeUrl={qrCodeUrl}
colors={colors}
branding={branding}
background={background}
/>
</div>
</Atropos>
<style jsx global>{`
.atropos-badge {
perspective: 1200px;
}
.atropos-badge .atropos-scale,
.atropos-badge .atropos-rotate {
transform-style: preserve-3d;
}
.atropos-badge .atropos-inner {
transform-style: preserve-3d;
}
.atropos-badge .atropos-shadow {
filter: blur(30px);
background: radial-gradient(
ellipse at center,
rgba(0, 0, 0, 0.35) 0%,
transparent 70%
);
}
.atropos-badge .atropos-highlight {
background: radial-gradient(
ellipse at center,
rgba(255, 255, 255, 0.15) 0%,
transparent 60%
);
}
`}</style>
</div>
);
});
registry/default/blocks/badges/ai-badge/components/elements/badge-preview.tsx
"use client";
import { forwardRef, useEffect, useState } from "react";
import QRCode from "qrcode";
import { cn } from "@/lib/utils";
export interface BadgeColors {
background: string;
foreground: string;
primary: string;
muted: string;
mutedForeground: string;
}
export interface BadgeBranding {
title: string;
subtitle: string;
logo?: React.ReactNode;
organizationName?: string;
organizationSubtitle?: string;
}
export type BadgePatternType =
| "none"
| "radial"
| "grid"
| "dots"
| "waves"
| "hexagons"
| "circuits";
export interface BadgeBackground {
/** Background pattern type */
pattern?: BadgePatternType;
/** Pattern opacity (0-1) */
patternOpacity?: number;
/** Pattern color (defaults to foreground) */
patternColor?: string;
/** Custom background image URL */
imageUrl?: string;
/** Background image opacity (0-1) */
imageOpacity?: number;
/** Gradient stops (e.g., ["#ff0000", "#0000ff"]) */
gradient?: string[];
/** Gradient direction in degrees (default: 135) */
gradientAngle?: number;
}
export interface BadgePreviewProps {
profilePictureUrl?: string | null;
badgeNumber: string;
firstName: string;
lastName?: string;
jobTitle: string;
qrCodeUrl?: string;
colors?: Partial<BadgeColors>;
branding?: BadgeBranding;
background?: BadgeBackground;
className?: string;
}
const DEFAULT_COLORS: BadgeColors = {
background: "#FAFAFA",
foreground: "#0A0A0A",
primary: "#00FF7F",
muted: "#666666",
mutedForeground: "#999999",
};
const DEFAULT_BRANDING: BadgeBranding = {
title: "100 DAYS",
subtitle: "OF SHIPPING",
};
const DEFAULT_BACKGROUND: BadgeBackground = {
pattern: "radial",
patternOpacity: 0.08,
};
// Badge dimensions - square photo area
const BADGE_WIDTH = 1080;
const BADGE_HEIGHT = 1600;
const PHOTO_SIZE = 960;
// Pre-calculate radial line endpoints to avoid hydration mismatches
const RADIAL_LINES = Array.from({ length: 24 }).map((_, i) => ({
key: `radial-${i}`,
x2: Math.round(BADGE_WIDTH / 2 + Math.cos((i * 15 * Math.PI) / 180) * 1400),
y2: Math.round(BADGE_HEIGHT / 2 + Math.sin((i * 15 * Math.PI) / 180) * 1400),
}));
// Pre-calculate octagon points
const OCTAGON_POINTS = [200, 400, 600, 800, 1000, 1200].map((size, idx) => ({
key: `octagon-${idx}`,
points: Array.from({ length: 8 })
.map((_, i) => {
const angle = (i * 45 - 22.5) * (Math.PI / 180);
const x = Math.round(BADGE_WIDTH / 2 + Math.cos(angle) * size);
const y = Math.round(BADGE_HEIGHT / 2 + Math.sin(angle) * size);
return `${x},${y}`;
})
.join(" "),
}));
// Pre-calculate hexagon positions
const HEXAGON_DATA = Array.from({ length: 12 }).flatMap((_, row) =>
Array.from({ length: 10 }).map((_, col) => {
const size = 60;
const xOffset = row % 2 === 0 ? 0 : size * 0.866;
const x = Math.round(col * size * 1.732 + xOffset);
const y = Math.round(row * size * 1.5);
const points = Array.from({ length: 6 })
.map((_, i) => {
const angle = (i * 60 - 30) * (Math.PI / 180);
return `${Math.round(x + Math.cos(angle) * size)},${Math.round(y + Math.sin(angle) * size)}`;
})
.join(" ");
return { key: `hex-${row}-${col}`, points };
}),
);
// Pre-calculate wave paths
const WAVE_PATHS = Array.from({ length: 20 }).map((_, i) => ({
key: `wave-${i}`,
d: `M 0 ${i * 80 + 40} Q ${BADGE_WIDTH / 4} ${i * 80} ${BADGE_WIDTH / 2} ${i * 80 + 40} T ${BADGE_WIDTH} ${i * 80 + 40}`,
}));
// Pre-calculate grid lines
const GRID_VERTICAL = Array.from({ length: 22 }).map((_, i) => ({
key: `v-${i}`,
x: i * 50,
}));
const GRID_HORIZONTAL = Array.from({ length: 33 }).map((_, i) => ({
key: `h-${i}`,
y: i * 50,
}));
// Pre-calculate dots
const DOTS_DATA = Array.from({ length: 22 }).flatMap((_, i) =>
Array.from({ length: 33 }).map((_, j) => ({
key: `dot-${i}-${j}`,
cx: i * 50 + 25,
cy: j * 50 + 25,
})),
);
// Pre-calculate circuit pattern
const CIRCUIT_VERTICAL = Array.from({ length: 15 }).map((_, i) => ({
key: `circuit-v-${i}`,
x: (i * 75) % BADGE_WIDTH,
nodes: Array.from({ length: 8 }).map((_, j) => ({
key: `node-${i}-${j}`,
cy: j * 200 + 100,
})),
}));
const CIRCUIT_HORIZONTAL = Array.from({ length: 10 }).map((_, i) => ({
key: `h-circuit-${i}`,
y: i * 160 + 80,
}));
// Pre-calculate gradient stops helper
const getGradientStops = (colors: string[]) =>
colors.map((color, i) => ({
key: `stop-${i}`,
offset: `${(i / (colors.length - 1)) * 100}%`,
color,
}));
function BackgroundPattern({
type,
color,
opacity,
}: {
type: BadgePatternType;
color: string;
opacity: number;
}) {
if (type === "none") return null;
return (
<g clipPath="url(#bgClip)" opacity={opacity}>
{type === "radial" && (
<>
{/* Radial lines from center */}
{RADIAL_LINES.map((line) => (
<line
key={line.key}
x1={BADGE_WIDTH / 2}
y1={BADGE_HEIGHT / 2}
x2={line.x2}
y2={line.y2}
stroke={color}
strokeWidth="2"
/>
))}
{/* Concentric octagons */}
{OCTAGON_POINTS.map((octagon) => (
<polygon
key={octagon.key}
points={octagon.points}
fill="none"
stroke={color}
strokeWidth="1.5"
/>
))}
</>
)}
{type === "grid" && (
<>
{/* Vertical lines */}
{GRID_VERTICAL.map((line) => (
<line
key={line.key}
x1={line.x}
y1={0}
x2={line.x}
y2={BADGE_HEIGHT}
stroke={color}
strokeWidth="1"
/>
))}
{/* Horizontal lines */}
{GRID_HORIZONTAL.map((line) => (
<line
key={line.key}
x1={0}
y1={line.y}
x2={BADGE_WIDTH}
y2={line.y}
stroke={color}
strokeWidth="1"
/>
))}
</>
)}
{type === "dots" &&
DOTS_DATA.map((dot) => (
<circle key={dot.key} cx={dot.cx} cy={dot.cy} r="3" fill={color} />
))}
{type === "waves" &&
WAVE_PATHS.map((wave) => (
<path
key={wave.key}
d={wave.d}
fill="none"
stroke={color}
strokeWidth="1.5"
/>
))}
{type === "hexagons" &&
HEXAGON_DATA.map((hex) => (
<polygon
key={hex.key}
points={hex.points}
fill="none"
stroke={color}
strokeWidth="1"
/>
))}
{type === "circuits" && (
<>
{/* Circuit-like pattern */}
{CIRCUIT_VERTICAL.map((circuit) => (
<g key={circuit.key}>
<line
x1={circuit.x}
y1={0}
x2={circuit.x}
y2={BADGE_HEIGHT}
stroke={color}
strokeWidth="1"
strokeDasharray="10,20"
/>
{circuit.nodes.map((node) => (
<circle
key={node.key}
cx={circuit.x}
cy={node.cy}
r="4"
fill={color}
/>
))}
</g>
))}
{CIRCUIT_HORIZONTAL.map((line) => (
<line
key={line.key}
x1={0}
y1={line.y}
x2={BADGE_WIDTH}
y2={line.y}
stroke={color}
strokeWidth="1"
strokeDasharray="15,25"
/>
))}
</>
)}
</g>
);
}
export const BadgePreview = forwardRef<SVGSVGElement, BadgePreviewProps>(
function BadgePreview(
{
profilePictureUrl,
badgeNumber,
firstName,
lastName = "",
jobTitle,
qrCodeUrl,
colors: customColors,
branding: customBranding,
background: customBackground,
className,
},
ref,
) {
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>("");
const colors = { ...DEFAULT_COLORS, ...customColors };
const branding = { ...DEFAULT_BRANDING, ...customBranding };
const background = { ...DEFAULT_BACKGROUND, ...customBackground };
useEffect(() => {
const generateQR = async () => {
try {
const numberOnly = badgeNumber.replace("#", "");
const url =
qrCodeUrl ||
(typeof window !== "undefined"
? `${window.location.origin}/p/${numberOnly}`
: "");
if (!url) return;
const qrDataUrl = await QRCode.toDataURL(url, {
width: 120,
margin: 0,
color: {
dark: colors.foreground,
light: colors.background,
},
});
setQrCodeDataUrl(qrDataUrl);
} catch (error) {
console.error("Error generating QR code:", error);
}
};
generateQR();
}, [badgeNumber, qrCodeUrl, colors.foreground, colors.background]);
const getNameFontSize = (name: string) => {
if (name.length > 12) return "52";
if (name.length > 8) return "64";
return "72";
};
const gradientId = "badgeGradient";
const hasGradient = background.gradient && background.gradient.length >= 2;
return (
<div
data-slot="badge-preview"
className={cn("w-full overflow-hidden", className)}
>
<div
className="relative w-full overflow-hidden"
style={{ aspectRatio: `${BADGE_WIDTH}/${BADGE_HEIGHT}` }}
>
<svg
ref={ref}
width={BADGE_WIDTH}
height={BADGE_HEIGHT}
viewBox={`0 0 ${BADGE_WIDTH} ${BADGE_HEIGHT}`}
className="absolute top-0 left-0 w-full h-full"
preserveAspectRatio="xMidYMid meet"
style={{ overflow: "hidden" }}
role="img"
aria-labelledby="badge-title"
>
<title id="badge-title">{`${firstName} ${lastName} - ${branding.title} Badge`}</title>
<defs>
<filter id="grayscale">
<feColorMatrix
type="matrix"
values="0.33 0.33 0.33 0 0
0.33 0.33 0.33 0 0
0.33 0.33 0.33 0 0
0 0 0 1 0"
/>
</filter>
<clipPath id="photoClip">
<rect x="60" y="60" width={PHOTO_SIZE} height={PHOTO_SIZE} />
</clipPath>
<clipPath id="bgClip">
<rect width={BADGE_WIDTH} height={BADGE_HEIGHT} />
</clipPath>
{/* Gradient definition */}
{hasGradient && (
<linearGradient
id={gradientId}
x1="0%"
y1="0%"
x2="100%"
y2="100%"
gradientTransform={`rotate(${background.gradientAngle || 135}, 0.5, 0.5)`}
>
{getGradientStops(background.gradient || []).map((stop) => (
<stop
key={stop.key}
offset={stop.offset}
stopColor={stop.color}
/>
))}
</linearGradient>
)}
</defs>
{/* Background - solid color or gradient */}
<rect
width={BADGE_WIDTH}
height={BADGE_HEIGHT}
fill={hasGradient ? `url(#${gradientId})` : colors.background}
/>
{/* Custom background image */}
{background.imageUrl && (
<image
href={background.imageUrl}
x="0"
y="0"
width={BADGE_WIDTH}
height={BADGE_HEIGHT}
preserveAspectRatio="xMidYMid slice"
opacity={background.imageOpacity ?? 0.3}
/>
)}
{/* Background pattern */}
<BackgroundPattern
type={background.pattern || "radial"}
color={background.patternColor || colors.foreground}
opacity={background.patternOpacity ?? 0.08}
/>
{/* Photo area background */}
<rect
x="60"
y="60"
width={PHOTO_SIZE}
height={PHOTO_SIZE}
fill="#E5E5E5"
/>
{/* Profile picture */}
{profilePictureUrl && (
<image
href={profilePictureUrl}
x="60"
y="60"
width={PHOTO_SIZE}
height={PHOTO_SIZE}
filter="url(#grayscale)"
preserveAspectRatio="xMidYMid slice"
clipPath="url(#photoClip)"
/>
)}
{/* L-Bracket frame element */}
<path
d="M60 1020 L60 1260 L200 1260"
fill="none"
stroke={colors.foreground}
strokeWidth="16"
/>
{/* Badge Number */}
<text
x="100"
y="1080"
fill={colors.muted}
fontSize="28"
fontFamily="'Geist Mono', monospace"
letterSpacing="0.15em"
>
{badgeNumber}
</text>
{/* First Name */}
<text
x="100"
y="1160"
fill={colors.foreground}
fontSize={getNameFontSize(firstName)}
fontWeight="700"
fontFamily="'Geist', 'Geist Mono', monospace"
letterSpacing="-0.02em"
>
{firstName.toUpperCase()}
</text>
{/* Last Name */}
{lastName && (
<text
x="100"
y="1240"
fill={colors.foreground}
fontSize={getNameFontSize(lastName)}
fontWeight="700"
fontFamily="'Geist', 'Geist Mono', monospace"
letterSpacing="-0.02em"
>
{lastName.toUpperCase()}
</text>
)}
{/* Role */}
<text
x="100"
y={lastName ? "1300" : "1220"}
fill={colors.muted}
fontSize="24"
fontFamily="'Geist Mono', monospace"
letterSpacing="0.1em"
>
→ {jobTitle.toUpperCase()}
</text>
{/* Organization branding - right side */}
{(branding.organizationName || branding.logo) && (
<g transform="translate(1020, 1060)">
{branding.logo}
{branding.organizationName && (
<>
<text
x="0"
y="60"
fill={colors.muted}
fontSize="20"
fontFamily="'Geist Mono', monospace"
letterSpacing="0.1em"
textAnchor="end"
>
{branding.organizationName.toUpperCase()}
</text>
{branding.organizationSubtitle && (
<text
x="0"
y="88"
fill={colors.foreground}
fontSize="20"
fontWeight="700"
fontFamily="'Geist Mono', monospace"
letterSpacing="0.1em"
textAnchor="end"
>
{branding.organizationSubtitle.toUpperCase()}
</text>
)}
</>
)}
</g>
)}
{/* Bottom divider line */}
<line
x1="60"
y1="1360"
x2="1020"
y2="1360"
stroke={colors.foreground}
strokeWidth="2"
/>
{/* Brand title */}
<text
x="100"
y="1460"
fill={colors.foreground}
fontSize="56"
fontWeight="700"
fontFamily="'Geist', 'Geist Mono', monospace"
letterSpacing="-0.02em"
>
{branding.title}
</text>
{/* Accent square next to brand */}
<rect
x="460"
y="1418"
width="48"
height="48"
fill={colors.primary}
/>
{/* Brand subtitle */}
<text
x="100"
y="1520"
fill={colors.muted}
fontSize="20"
fontFamily="'Geist Mono', monospace"
letterSpacing="0.2em"
>
{branding.subtitle}
</text>
{/* QR Code */}
{qrCodeDataUrl && (
<image
href={qrCodeDataUrl}
x="900"
y="1420"
width="120"
height="120"
/>
)}
{/* Corner accents - top left */}
<rect x="60" y="60" width="8" height="40" fill={colors.primary} />
<rect x="60" y="60" width="40" height="8" fill={colors.primary} />
{/* Corner accents - top right */}
<rect x="1012" y="60" width="8" height="40" fill={colors.primary} />
<rect x="980" y="60" width="40" height="8" fill={colors.primary} />
{/* Corner accents - bottom left of photo */}
<rect x="60" y="980" width="8" height="40" fill={colors.primary} />
<rect x="60" y="1012" width="40" height="8" fill={colors.primary} />
{/* Corner accents - bottom right of photo */}
<rect
x="1012"
y="980"
width="8"
height="40"
fill={colors.primary}
/>
<rect
x="980"
y="1012"
width="40"
height="8"
fill={colors.primary}
/>
</svg>
</div>
</div>
);
},
);