Backgrounds
Halftone Ripple
A tiled dot matrix with rings travelling out through it, moved by animating mask-size rather than redrawing a single dot.
Original by Plinth · MIT
Report a problem or suggest an improvementInstall
npx shadcn@latest add https://plinthui.com/r/halftone-ripple.json// No hooks or handlers, so this stays a server component — it is pure CSS.
// Masked repeating gradients and blend modes are outside what Tailwind can
// express, so the CSS ships as a string.
const css = `
.halftone {
position: relative;
min-height: 300px;
height: 100%;
overflow: hidden;
background: #070310;
isolation: isolate;
}
/* One element paints the entire dot matrix: a radial-gradient tiled by
background-size. N dots would be N elements and N layout boxes; this is one
paint whatever the density. */
.halftone__dots {
position: absolute;
inset: -10%;
background-image: radial-gradient(circle at center, #fff 0 3.4px, transparent 3.9px);
background-size: 13px 13px;
}
/* The ripple is a MASK, not a change to the dots. Rings of opaque and clear
sweep outward, and each dot simply shows where a ring is currently over it,
so the matrix itself never re-renders. Scaling mask-size is what moves the
rings: the whole repeating pattern grows from the origin, which reads as a
wave travelling out. Gradient colour stops cannot be animated without
@property, but mask-size can, so the motion is free. */
.halftone__dots--a {
-webkit-mask-image: repeating-radial-gradient(circle at 46% 52%, #000 0 15%, transparent 15% 26%);
mask-image: repeating-radial-gradient(circle at 46% 52%, #000 0 15%, transparent 15% 26%);
animation: halftone-out 6.5s linear infinite;
}
.halftone__dots--b {
-webkit-mask-image: repeating-radial-gradient(circle at 46% 52%, #000 0 15%, transparent 15% 26%);
mask-image: repeating-radial-gradient(circle at 46% 52%, #000 0 15%, transparent 15% 26%);
/* Same wave, half a period behind: the gaps of one land on the crests of the
other, so the field never fully empties between rings. */
animation: halftone-out 6.5s linear infinite -3.25s;
opacity: 0.72;
}
@keyframes halftone-out {
from { -webkit-mask-size: 30% 30%; mask-size: 30% 30%; opacity: 0; }
18% { opacity: 1; }
to { -webkit-mask-size: 210% 210%; mask-size: 210% 210%; opacity: 0; }
}
/* multiply over a white dot field: gradient x white = gradient, and the gaps
stay black. One gradient colours every dot, so density changes never shift
the palette. */
.halftone__tint {
position: absolute;
inset: 0;
background: linear-gradient(120deg, #22d3ee 0%, #6366f1 45%, #ec4899 100%);
mix-blend-mode: multiply;
pointer-events: none;
}
.halftone__core {
position: absolute;
inset: 0;
background: radial-gradient(28% 28% at 46% 52%, rgba(255, 255, 255, 0.16), transparent 55%);
mix-blend-mode: screen;
pointer-events: none;
}
.halftone__vignette {
position: absolute;
inset: 0;
background: radial-gradient(115% 85% at 46% 52%, transparent 66%, rgba(3, 1, 8, 0.5) 100%);
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.halftone__dots { animation: none; }
.halftone__dots--a { -webkit-mask-size: 120% 120%; mask-size: 120% 120%; opacity: 1; }
.halftone__dots--b { display: none; }
}
`;
export function HalftoneRipple() {
return (
<>
<style>{css}</style>
<div className="halftone">
<div className="halftone__dots halftone__dots--a" />
<div className="halftone__dots halftone__dots--b" />
<div className="halftone__tint" />
<div className="halftone__core" />
<div className="halftone__vignette" />
</div>
</>
);
}