Cover pictures drawn from a seed in your theme's chart colors: color fields, rings, bars, dots or waves. The same seed always draws the same picture, so an item without an image still looks finished.
import { CoverArt, type CoverArtVariant } from "@/components/ballmac/cover-art"
const variants: CoverArtVariant[] = ["mesh", "rings", "bars", "dots", "waves"]
export default function CoverArtDemo() {
return (
<div className="grid w-full max-w-3xl grid-cols-2 gap-3 sm:grid-cols-3">
{variants.map((variant, i) => (
<figure key={variant} className="grid gap-1.5">
<CoverArt seed={`demo-${i}`} variant={variant} grid={variant === "mesh"} className="aspect-video rounded-xl border" />
<figcaption className="text-muted-foreground text-center text-xs">{variant}</figcaption>
</figure>
))}
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/cover-artCopy the source into your project.
components/ballmac/cover-art.tsx// Ballmac UI: Cover Art. https://ui.ballmac.com/components/cover-art import * as React from "react" import { cn } from "@/lib/utils" type CoverArtVariant = "mesh" | "rings" | "bars" | "dots" | "waves" type CoverArtProps = Omit<React.ComponentProps<"div">, "children"> & { /** Anything stable: an id, a name, a slug. The same seed always draws the same picture. */ seed: string | number /** Which picture to draw. Leave it out (or "auto") to let the seed choose among the five. */ variant?: CoverArtVariant | "auto" /** Lay a faint grid over the picture. */ grid?: boolean } const variants: CoverArtVariant[] = ["mesh", "rings", "bars", "dots", "waves"] const tones = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"] /** A small deterministic random source (mulberry32). Integer maths only, so the server and every browser draw the same numbers. */ function random(seed: string | number) { let h = 2166136261 for (const ch of String(seed)) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) let a = h >>> 0 return () => { a = (a + 0x6d2b79f5) >>> 0 let t = Math.imul(a ^ (a >>> 15), 1 | a) t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t return (((t ^ (t >>> 14)) >>> 0) % 100000) / 100000 } } /** Rounded so the markup is short and identical wherever it renders. */ const n = (v: number) => Math.round(v * 10) / 10 /** * A cover picture drawn from a seed, in the theme's chart colors: soft color fields, rings, bars, dots or waves. Use it where an * item has no image of its own: a project card, a profile cover, a channel or agent avatar. It fills its parent, never takes * focus, and is hidden from assistive technology. */ function CoverArt({ seed, variant = "auto", grid = false, className, ...props }: CoverArtProps) { const rnd = random(seed) const pick = <T,>(list: T[]) => list[Math.floor(rnd() * list.length)]! const kind = variant === "auto" ? pick(variants) : variant const first = Math.floor(rnd() * tones.length) const tone = tones[first]! const alt = tones[(first + 1 + Math.floor(rnd() * 3)) % tones.length]! const third = tones[(first + 2 + Math.floor(rnd() * 2)) % tones.length]! let shapes: React.ReactNode = null let field: React.CSSProperties["background"] = `linear-gradient(135deg, color-mix(in oklab, ${tone} 22%, transparent), color-mix(in oklab, ${alt} 14%, transparent))` if (kind === "mesh") { // one blob in each third of the width, so a wide cover is never empty on one side const slots = [0, 1, 2] const at = (slot: number) => `${Math.round(slot * 34 + rnd() * 32)}% ${Math.round(8 + rnd() * 84)}%` field = [ `radial-gradient(60% 90% at ${at(slots[0]!)}, color-mix(in oklab, ${tone} 60%, transparent), transparent 70%)`, `radial-gradient(55% 85% at ${at(slots[1]!)}, color-mix(in oklab, ${alt} 50%, transparent), transparent 70%)`, `radial-gradient(70% 90% at ${at(slots[2]!)}, color-mix(in oklab, ${third} 42%, transparent), transparent 72%)`, ].join(",") } else if (kind === "rings") { const cx = 190 + rnd() * 90 const cy = 50 + rnd() * 80 shapes = ( <> {[26, 52, 80, 112, 148].map((r, i) => ( <circle key={r} cx={n(cx)} cy={n(cy)} r={r} fill="none" stroke={i % 2 ? alt : tone} strokeWidth={i === 0 ? 2 : 1.4} strokeOpacity={n(0.7 - i * 0.12)} /> ))} <circle cx={n(cx)} cy={n(cy)} r="9" fill={tone} fillOpacity="0.9" /> <circle cx={n(cx - 62 - rnd() * 20)} cy={n(cy + 30 + rnd() * 20)} r="5" fill={third} fillOpacity="0.8" /> </> ) } else if (kind === "bars") { const count = 11 + Math.floor(rnd() * 4) const width = 320 / count const heights = Array.from({ length: count }, (_, i) => n(36 + rnd() * 96 + i * 2)) const peak = heights.indexOf(Math.max(...heights)) shapes = ( <> {heights.map((h, i) => ( <rect key={i} x={n(i * width + width * 0.2)} y={n(180 - h)} width={n(width * 0.6)} height={h} rx={n(width * 0.3)} fill={i === peak ? alt : tone} fillOpacity={i === peak ? 0.95 : n(0.28 + (i / count) * 0.4)} /> ))} </> ) } else if (kind === "dots") { const fx = 80 + rnd() * 170 const fy = 40 + rnd() * 100 const dots: React.ReactNode[] = [] for (let row = 0; row < 8; row++) for (let col = 0; col < 15; col++) { const x = 12 + col * 21.4 const y = 14 + row * 22.5 // size falls off with distance from the focus, using squares instead of a square root const d2 = ((x - fx) * (x - fx) + (y - fy) * (y - fy)) / 9000 const r = Math.max(1.2, 6.2 - d2 * 4.2) dots.push(<circle key={`${row}-${col}`} cx={n(x)} cy={n(y)} r={n(r)} fill={d2 < 0.7 ? alt : tone} fillOpacity={n(Math.max(0.18, 0.9 - d2 * 0.55))} />) } shapes = dots } else { const wave = (y: number, amp: number) => { const a = n(y + (rnd() - 0.5) * amp) const b = n(y + (rnd() - 0.5) * amp * 1.6) const c = n(y + (rnd() - 0.5) * amp) return `M0 ${n(y)} C 80 ${a}, 120 ${b}, 175 ${c} S 270 ${n(y + (rnd() - 0.5) * amp)}, 320 ${n(y + (rnd() - 0.5) * amp)}` } shapes = ( <> {[ { y: 62, w: 2.4, c: tone, o: 0.9 }, { y: 92, w: 1.8, c: alt, o: 0.7 }, { y: 120, w: 1.4, c: third, o: 0.55 }, { y: 146, w: 1.1, c: tone, o: 0.35 }, ].map((l) => ( <path key={l.y} d={wave(l.y, 56)} fill="none" stroke={l.c} strokeWidth={l.w} strokeOpacity={l.o} strokeLinecap="round" /> ))} </> ) } return ( <div aria-hidden="true" data-slot="cover-art" data-variant={kind} className={cn("bg-muted/60 pointer-events-none relative isolate size-full overflow-hidden", className)} {...props} > <div className="absolute inset-0" style={{ background: field }} /> {shapes ? ( <svg viewBox="0 0 320 180" preserveAspectRatio="xMidYMid slice" className="absolute inset-0 size-full"> {shapes} </svg> ) : null} {grid ? ( <svg viewBox="0 0 320 180" preserveAspectRatio="xMidYMid slice" className="text-foreground absolute inset-0 size-full opacity-[0.06]"> <path d={`${Array.from({ length: 9 }, (_, i) => `M${(i + 1) * 32} 0V180`).join("")}${Array.from({ length: 5 }, (_, i) => `M0 ${(i + 1) * 30}H320`).join("")}`} stroke="currentColor" strokeWidth="1" vectorEffect="non-scaling-stroke" fill="none" /> </svg> ) : null} </div> ) } export { CoverArt, type CoverArtProps, type CoverArtVariant }Update the import paths to match your project setup.
Usage
import { CoverArt } from "@/components/ballmac/cover-art"The full example is in the Code tab above.
Examples
One per name
- Launch
- Design
- Billing
- Support
- Research
- Hiring
- Docs
- Ops
import { CoverArt } from "@/components/ballmac/cover-art"
const names = ["Launch", "Design", "Billing", "Support", "Research", "Hiring", "Docs", "Ops"]
/** The same seed gives the same picture every time, so a name is enough to make a set of avatars. */
export default function CoverArtSeeds() {
return (
<ul className="flex max-w-md flex-wrap justify-center gap-3">
{names.map((name) => (
<li key={name} className="grid justify-items-center gap-1.5">
<CoverArt seed={name} variant="mesh" className="size-14 rounded-2xl border" />
<span className="text-xs">{name}</span>
</li>
))}
</ul>
)
}API reference
| Prop | Type | Default |
|---|---|---|
seed*Anything stable: an id, a name, a slug. The same seed always draws the same picture. | string | number | — |
variantWhich picture to draw. Leave it out (or "auto") to let the seed choose among the five. | CoverArtVariant | "auto" | "auto" |
gridLay a faint grid over the picture. | boolean | false |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| None | Decorative: aria-hidden, no focus. Never the only way to tell items apart. |
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 2 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
No endless animation to begin with.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (0% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Not applicable: Keyboard
Static content: nothing to focus.
Not applicable: Loading, empty and error
Does not show data or take submissions.
Passed: Automated tests
Covered by cover-art.test.tsx.
For information: Footprint
2.6 KB of source (gzip) in 1 file. No extra npm packages.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Use with AI
<CoverArt seed={project.id} /> fills its parent with a picture chosen and drawn from the seed; variant: 'mesh' | 'rings' | 'bars' | 'dots' | 'waves' | 'auto'; grid adds a faint grid. Put it in a sized container (an aspect-video div, a size-10 rounded avatar). It is decorative and hidden from screen readers, so keep the item's name as text next to it. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Cover Art (@ballmac/cover-art) to this project with the shadcn MCP, then use it where it fits.
Use it for
- The fallback for a card, channel, project or agent that has no image
- A profile cover or a thumbnail while images load
Not for
- A real photograph or logo (use media)
- An animated hero background (use aurora-background or beams-background)
Registry JSON: https://ui.ballmac.com/r/cover-art.json
Credits
Free to use in personal and commercial projects.
- npm
- None
- Registry
- shadcn/utils
Pairs well with
Media
A picture slot: reserves the space, lazy-loads, takes a URL, an image with required alt text and a dark-mode file, or your own element, and falls back to artwork if there is no image or it fails.
Card
A composable content surface with compact spacing, an action slot, and optional interactive feedback.
Avatar
A Radix avatar with image and initials fallback, three sizes, an optional presence dot, and AvatarGroup for overlapping stacks with a +N overflow counter.
Aurora Background
Soft, slowly drifting aurora light made of blurred token-colored glows with fine vertical curtains and an optional radial fade. Pure CSS layers, paused off-screen, still under reduced motion.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.